Skip to content

Instantly share code, notes, and snippets.

@hdoro
Last active August 26, 2022 16:48
Show Gist options
  • Save hdoro/fd69b672bf6a9768b06edd3594a0e7cd to your computer and use it in GitHub Desktop.
Save hdoro/fd69b672bf6a9768b06edd3594a0e7cd to your computer and use it in GitHub Desktop.
Minimal TicTacToe game, with UI in SvelteJS - repo with tests and npm set-up: https://github.com/hdoro/tictactoe-rc
export const STATE_ERRORS = {
nonArray: "Invalid state. Should be an array.",
rowCount: "Invalid number of rows",
invalidCell: "Invalid cell value",
columnCount: "Row with invalid number of columns",
circleMoves: "Circle player has more moves than Cross",
crossMoves: "Cross player is more than 1 move ahead of Circle",
};
export const STATE_RETURNS = {
ongoing: "ongoing",
draw: "draw",
[0]: "O-wins",
[1]: "X-wins",
};
/**
* gameState is an array of tuples of 1 for X, 0 for O & null for empty cell.
* @param {
* [1, 0, null][]
* } gameState
*
* @returns "ongoing" | "draw" | "O-wins" | "X-wins"
*/
export function validateState(gameState) {
if (!Array.isArray(gameState)) {
throw new Error(STATE_ERRORS.nonArray);
}
if (gameState.length !== 3) {
throw new Error(STATE_ERRORS.rowCount);
}
if (gameState.some((row) => row.length !== 3)) {
throw new Error(STATE_ERRORS.columnCount);
}
if (gameState.flat().some((cell) => ![1, 0, null].includes(cell))) {
throw new Error(STATE_ERRORS.invalidCell);
}
const valueCount = gameState.reduce((count, curRow) => {
return {
X: (count["X"] || 0) + curRow.filter((cell) => cell === 1).length,
O: (count["O"] || 0) + curRow.filter((cell) => cell === 0).length,
empty:
(count["empty"] || 0) + curRow.filter((cell) => cell === null).length,
};
}, {});
if (valueCount.O > valueCount.X) {
throw new Error(STATE_ERRORS.circleMoves);
}
if (valueCount.X - valueCount.O > 1) {
throw new Error(STATE_ERRORS.crossMoves);
}
// Switch from rows to columns before we can analyze column wins
const columns = gameState.reduce((cols, row) => {
return row.map((cell, index) => [...(cols[index] || []), cell]);
}, []);
// Switch from rows to diagonals before we can analyze diagonals wins
const diagonals = [
// 1st index in 1st row -> last index in last row
[gameState[0][0], gameState[1][1], gameState[2][2]],
// Last index in 1st row -> 1st index in last row
[gameState[2][0], gameState[1][1], gameState[0][2]],
];
console.log({ diagonals });
const winState = [
...gameState, // row wins
...columns, // column wins
...diagonals, // diagonal wins
]
.map((sequence) => {
// If the sequence starts with a number, let's check if it's fully made out of that number
if (Number.isInteger(sequence[0])) {
// If we find any cell that is different from the first cell in the sequence, we don't have a win state
return sequence.some((cell) => cell !== sequence[0])
? undefined
: // Else, return the proper win state for the given number
STATE_RETURNS[sequence[0]];
}
})
.find((state) => !!state);
if (winState) {
return winState;
}
// If no player has won, return draw ONYL if valueCount.empty === 0
if (valueCount.empty === 0) {
return "draw";
}
// Otherwise, game still running
return "ongoing";
}
<script>
import { STATE_RETURNS, validateState } from "./gameState";
/**
* What goes into the game loop:
*
* 1. Prompt the first player (Cross) for their move
* 2. Moves are composed of [rowNumber, columnNumber]
* 3. If move is invalid, re-prompt
* - Spot already filled
* - Do a pre-flight validateState with the new value, act on errors thrown (invalid row count, cell value, etc.)
* 4. Check new gameState
* 5. If win or draw, end of game state
* 6. If ongoing, next player
*/
const INITIAL_STATE = [
[null, null, null],
[null, null, null],
[null, null, null],
];
let gameState = INITIAL_STATE;
// 1 (Cross) or 0 (Circle)
let currentPlayer = 1;
const CELL_VALUES = {
1: "X",
0: "O",
};
const CONCLUSION_LABELS = {
[STATE_RETURNS[0]]: "Circle wins!",
[STATE_RETURNS[1]]: "Cross wins!",
[STATE_RETURNS.draw]: "It's a tie!",
};
function registerPlay(rowIndex, cellIndex, player) {
if (gameState[rowIndex][cellIndex] != null) {
// @TODO: user feedback on error
return;
}
const newState = gameState.map((row, rowIdx) => {
if (rowIdx === rowIndex) {
return row.map((cell, cellIdx) => {
if (cellIdx === cellIndex) {
return player;
}
return cell;
});
}
return row;
});
try {
validateState(newState);
gameState = newState;
currentPlayer = player === 0 ? 1 : 0;
} catch (error) {
return;
}
}
$: stateInfo = (function () {
try {
return validateState(gameState);
} catch (error) {
return "ongoing";
}
})();
</script>
<main>
<h1>Tic Tac Toe</h1>
{#if stateInfo !== "ongoing"}
<p>{CONCLUSION_LABELS[stateInfo]}</p>
<button on:click|preventDefault={() => (gameState = INITIAL_STATE)}
>Restart</button
>
{/if}
<div class="grid">
{#each gameState as row, rowIndex}
{#each row as cell, cellIndex}
<button
class="cell"
disabled={cell !== null || stateInfo !== "ongoing"}
data-value={cell}
on:click|preventDefault={() =>
registerPlay(rowIndex, cellIndex, currentPlayer)}
>
{CELL_VALUES[cell] || ""}
</button>
{/each}
{/each}
</div>
</main>
<style>
:global(body) {
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Oxygen,
Ubuntu, Cantarell, "Open Sans", "Helvetica Neue", sans-serif;
max-width: 500px;
margin: 0 auto;
}
.grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
}
.cell {
border: 1px solid black;
aspect-ratio: 1;
display: flex;
align-items: center;
justify-content: center;
font-size: 32px;
font-weight: 800;
}
.cell[data-value="1"] {
background: papayawhip;
}
.cell[data-value="0"] {
background: paleturquoise;
}
</style>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment