A full-stack Tic-Tac-Toe pet project. Server-side game logic with two robot opponents, served over a REST API and played in a React web client styled as a letterpress broadsheet — Fraunces & DM Mono on warm paper, with ink-stroke X / O marks
Live demo: https://vgartg.github.io/Tic-Tac-Toe — the Pages build bundles a client-side port of the game logic, so the deployed broadsheet plays end-to-end without the Spring Boot backend
- Two game modes —
SOLO(against the robot) andDUO(hot-seat for two players) - Three robot difficulties:
LITE— picks a random empty cellHARD— opens with center or a corner, then completes its own winning line when possibleIMPOSSIBLE— full minimax (with depth discount); cannot lose, plays itself to a draw
- Stateless client — game state lives on the server, keyed by a session id returned on game creation
- Validated REST API with structured error responses
- Unit-tested domain and service layers (JUnit 5 + AssertJ)
| Layer | Stack |
|---|---|
| Backend | Java 17, Spring Boot 3.3, Maven (with wrapper) |
| Frontend | React 18, TypeScript 5, Vite 5 |
| Storage | In-memory ConcurrentHashMap (one process) |
PET_Tic-Tac-Toe/
├── .github/workflows/ci.yml
├── backend/
│ ├── pom.xml
│ ├── mvnw / mvnw.cmd / mvnw.ps1
│ ├── run.sh / run.cmd
│ ├── .mvn/wrapper/
│ └── src/
│ ├── main/
│ │ ├── java/com/tictactoe/
│ │ │ ├── TicTacToeApplication.java
│ │ │ ├── api/ # REST controllers, DTOs, exception handler
│ │ │ │ ├── GameController.java
│ │ │ │ ├── dto/
│ │ │ │ └── exception/
│ │ │ ├── config/WebConfig.java
│ │ │ ├── domain/ # Pure game model
│ │ │ │ ├── Board.java
│ │ │ │ ├── Game.java
│ │ │ │ ├── Mark.java
│ │ │ │ ├── GameStatus.java
│ │ │ │ ├── GameMode.java
│ │ │ │ ├── Difficulty.java
│ │ │ │ └── ai/
│ │ │ │ ├── RobotPlayer.java
│ │ │ │ ├── LiteRobot.java
│ │ │ │ └── HardRobot.java
│ │ │ └── service/ # Application services
│ │ │ ├── GameService.java
│ │ │ ├── GameSessionStore.java
│ │ │ ├── InMemoryGameSessionStore.java
│ │ │ └── RobotResolver.java
│ │ └── resources/application.yml
│ └── test/java/com/tictactoe/ # JUnit 5 tests
│
├── frontend/
│ ├── package.json
│ ├── tsconfig.json
│ ├── vite.config.ts # /api proxy to localhost:3000
│ ├── index.html
│ └── src/
│ ├── main.tsx
│ ├── App.tsx
│ ├── types.ts
│ ├── styles.css
│ ├── api/gameApi.ts
│ ├── components/
│ │ ├── Board.tsx
│ │ ├── Cell.tsx
│ │ ├── GameSetup.tsx
│ │ └── StatusBanner.tsx
│ └── utils/
│ ├── statusMessage.ts
│ └── statusMessage.test.ts
│
├── README.md
└── .gitignore
- JDK 17+ (the build targets Java 17)
- Node.js 18+ and npm (for the frontend)
- Maven is not required — the project ships with the Maven Wrapper
From the project root:
cd backend
./run.sh # macOS / Linux
.\run.cmd # WindowsThe wrapper script packages the application and launches it with java -jar.
The API will be available at http://localhost:3000
To run the test suite:
cd backend
./mvnw testTo produce a runnable jar manually:
cd backend
./mvnw clean package
java -jar target/tictactoe-backend.jarNote on
spring-boot:run— on Windows themvn spring-boot:rungoal does not work when the project lives under a path that contains non-ASCII characters (e.g. CyrillicРабочий стол). The plugin forks a JVM and passes the classpath via the Windows command line, which the OS code page mangles. Therun.cmd/java -jarworkflow above avoids this entirely because the main class is resolved from the JAR'sMANIFEST.MF
cd frontend
npm install
npm run devThe dev server runs at http://localhost:8080 and proxies /api/* to the
backend on :3000, so start the backend first
Lint and unit tests:
cd frontend
npm run lint # ESLint (flat config)
npm run test # Vitest (run once)
npm run test:watch # Vitest in watch modeProduction bundle:
cd frontend
npm run build # outputs to frontend/dist
npm run preview # serves the production build locallyBase URL: http://localhost:3000/api/games (the React dev server on :8080 proxies /api/* to it)
POST /api/games
{
"mode": "SOLO",
"difficulty": "HARD",
"humanMark": "X"
}For DUO mode, difficulty and humanMark may be omitted
Response 201 Created:
{
"id": "0c3d...uuid",
"mode": "SOLO",
"difficulty": "HARD",
"humanMark": "X",
"robotMark": "O",
"cells": ["EMPTY", "EMPTY", "EMPTY", "EMPTY", "EMPTY", "EMPTY", "EMPTY", "EMPTY", "EMPTY"],
"currentTurn": "X",
"status": "IN_PROGRESS",
"finished": false
}GET /api/games/{id} → same payload as above
POST /api/games/{id}/moves
{ "cellIndex": 4 }cellIndex is the 0-based position on the 3x3 board:
0 | 1 | 2
-----------
3 | 4 | 5
-----------
6 | 7 | 8
In SOLO mode the response already reflects the robot's reply (if the game is not finished)
DELETE /api/games/{id} → 204 No Content
{
"timestamp": "2026-05-16T09:00:00Z",
"status": 400,
"error": "Invalid move",
"message": "Cell 4 is already occupied"
}GitHub Actions workflow at .github/workflows/ci.yml runs on every
push and pull request against main. It has two parallel jobs:
- Backend — sets up JDK 17, restores the Maven cache, runs
./mvnw test, then builds the jar. On failure, Surefire reports are uploaded as artifacts - Frontend — sets up Node 20, runs
npm ci, thennpm run lint,npm run test(Vitest), and finallynpm run build(which type-checks viatsc --noEmitand produces the Vite production bundle)
A separate workflow at .github/workflows/deploy.yml publishes
the broadsheet to GitHub Pages on every push to main. The Pages build is run with
VITE_USE_LOCAL=1, which swaps the /api/games calls for a TypeScript port of the
backend (frontend/src/api/local-generator.ts) — same game model, same Hard / Lite
strategies, all in-browser. Local dev still proxies /api/* to the live Spring Boot
server on :3000
One-time setup: open Settings → Pages → Build and deployment → Source = "GitHub Actions". Subsequent deploys are automatic
- Domain layer (
com.tictactoe.domain) is pure Java with no Spring imports — it can be tested in isolation and reused outside Spring Boardowns cell state, win detection (8 lines), copy, and snapshot for serializationGameis mutable; it advances the turn only when the move did not end the game- Robot players implement a single
RobotPlayerinterface and register themselves as Spring beans.RobotResolverexposes them byDifficulty, so adding a new level is a one-class change - Sessions are kept in
InMemoryGameSessionStore(aConcurrentHashMap). The interface is in place to swap in Redis / a database later without touching the controller or service code - CORS origins are configured in
application.ymlundertictactoe.cors.allowed-origins
This project started as a C# console application. The repository was rewritten end-to-end: the domain logic was reimplemented in idiomatic Java, exposed over a REST API, and a React client replaced the console UI. The original C# sources remain available in the Git history