Implement minimal client/server game prototype with Spring Boot backend and LWJGL frontend#1
Conversation
Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
Adds a minimal end-to-end runnable prototype consisting of a Spring Boot backend that owns game state/mechanics and an LWJGL frontend that renders the grid and issues commands via REST.
Changes:
- Introduces Spring Boot REST server with in-memory grid, armies, tick progression (village growth), and combat resolution.
- Adds LWJGL client that renders the grid/armies and calls
/state,/tick, and/commandover HTTP. - Adds helper scripts (
start-*.sh,demo-api.sh), plus README/.gitignore for running the prototype.
Reviewed changes
Copilot reviewed 22 out of 23 changed files in this pull request and generated 7 comments.
Show a summary per file
| File | Description |
|---|---|
| start-frontend.sh | Script to build/run the LWJGL client via Maven exec plugin |
| start-backend.sh | Script to run the Spring Boot server via Maven |
| demo-api.sh | Curl-based demo of state/tick/command API |
| README.md | Documents rules, endpoints, and run instructions |
| .gitignore | Ignores Maven/IDE/OS artifacts |
| backend/pom.xml | Spring Boot web backend project definition |
| backend/src/main/java/com/barony/backend/BackendApplication.java | Backend entry point |
| backend/src/main/java/com/barony/backend/controller/GameController.java | REST endpoints for state/tick/command |
| backend/src/main/java/com/barony/backend/service/GameService.java | Core game state + tick/command/combat logic |
| backend/src/main/java/com/barony/backend/model/* | Backend models for tiles, armies, commands, state |
| backend/src/main/resources/application.properties | Configures server port (8080) |
| frontend/pom.xml | LWJGL + Gson frontend project definition |
| frontend/src/main/java/com/barony/frontend/FrontendApplication.java | LWJGL window loop + simple input-to-API wiring |
| frontend/src/main/java/com/barony/frontend/client/GameClient.java | Simple HttpURLConnection REST client |
| frontend/src/main/java/com/barony/frontend/model/* | Frontend DTOs for JSON deserialization |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| public GameState getState() { | ||
| try { | ||
| URL url = new URL(baseUrl + "/state"); | ||
| HttpURLConnection conn = (HttpURLConnection) url.openConnection(); | ||
| conn.setRequestMethod("GET"); | ||
|
|
||
| StringBuilder response = new StringBuilder(); | ||
| try (BufferedReader in = new BufferedReader(new InputStreamReader(conn.getInputStream()))) { | ||
| String line; |
There was a problem hiding this comment.
HttpURLConnection is never disconnected and no connect/read timeouts are set. This can leak sockets and hang the render thread indefinitely if the backend is down or slow. Add reasonable timeouts and ensure conn.disconnect() is called in a finally block; also handle non-2xx responses via getErrorStream().
There was a problem hiding this comment.
Added connection timeout (5s), read timeout (10s), proper disconnect() in finally blocks, and error stream handling for non-2xx responses. Commit: 66c55d0
| conn.setRequestProperty("Content-Type", "application/json"); | ||
| conn.setDoOutput(true); | ||
|
|
||
| String jsonCommand = gson.toJson(command); | ||
| try (OutputStream os = conn.getOutputStream()) { | ||
| os.write(jsonCommand.getBytes()); | ||
| os.flush(); |
There was a problem hiding this comment.
jsonCommand.getBytes() uses the platform default charset, which can produce invalid JSON on non-UTF-8 platforms. Encode explicitly as UTF-8 (and ideally set the request charset in the Content-Type header).
There was a problem hiding this comment.
Changed to explicit UTF-8 encoding using StandardCharsets.UTF_8 and added charset to Content-Type header. Commit: 66c55d0
| <dependency> | ||
| <groupId>org.lwjgl</groupId> | ||
| <artifactId>lwjgl</artifactId> | ||
| <classifier>natives-linux</classifier> | ||
| </dependency> | ||
| <dependency> | ||
| <groupId>org.lwjgl</groupId> | ||
| <artifactId>lwjgl-glfw</artifactId> | ||
| <classifier>natives-linux</classifier> | ||
| </dependency> | ||
| <dependency> | ||
| <groupId>org.lwjgl</groupId> | ||
| <artifactId>lwjgl-opengl</artifactId> | ||
| <classifier>natives-linux</classifier> | ||
| </dependency> |
There was a problem hiding this comment.
The LWJGL dependencies hardcode natives-linux classifiers, so the frontend won’t run on macOS/Windows. Consider using OS detection (e.g., os-maven-plugin + a ${lwjgl.natives} property) or Maven profiles to select the correct natives per platform.
There was a problem hiding this comment.
Added Maven profiles for automatic platform detection (Linux, macOS Intel/ARM, Windows) using ${lwjgl.natives} property. Commit: 66c55d0
| public class GameService { | ||
| private GameState gameState; | ||
|
|
||
| public GameService() { | ||
| initializeGame(); | ||
| } | ||
|
|
||
| private void initializeGame() { | ||
| gameState = new GameState(10, 10); | ||
|
|
||
| // Set up initial board | ||
| gameState.getGrid()[0][0].setType(TileType.CASTLE); | ||
| gameState.getGrid()[9][9].setType(TileType.CASTLE); | ||
| gameState.getGrid()[3][3].setType(TileType.VILLAGE); | ||
| gameState.getGrid()[6][6].setType(TileType.VILLAGE); | ||
|
|
||
| // Add initial armies | ||
| gameState.getArmies().add(new Army(0, 0, 10, 1)); | ||
| gameState.getArmies().add(new Army(9, 9, 10, 2)); | ||
| } | ||
|
|
||
| public GameState getState() { | ||
| return gameState; | ||
| } | ||
|
|
||
| public void tick() { | ||
| gameState.incrementTick(); |
There was a problem hiding this comment.
GameService stores all mutable game state in a singleton Spring @service without any synchronization. Since controller requests can run concurrently, simultaneous /tick and /command calls can race (e.g., inconsistent combat results, ConcurrentModification during serialization, lost updates). Consider guarding all state mutations/reads with a lock (e.g., synchronized methods or a ReentrantLock) and/or returning an immutable snapshot DTO rather than the live GameState object.
| int armyIndex = command.getArmyIndex(); | ||
| if (armyIndex >= 0 && armyIndex < gameState.getArmies().size()) { | ||
| Army army = gameState.getArmies().get(armyIndex); | ||
| int targetX = command.getTargetX(); | ||
| int targetY = command.getTargetY(); | ||
|
|
||
| // Validate target position | ||
| if (targetX >= 0 && targetX < gameState.getWidth() && | ||
| targetY >= 0 && targetY < gameState.getHeight()) { | ||
| army.setX(targetX); | ||
| army.setY(targetY); |
There was a problem hiding this comment.
Using "armyIndex" to identify an army is unstable because list ordering can change after combat removals (or future sorting), causing commands to apply to the wrong army. Prefer a stable identifier on Army (e.g., armyId UUID/int) and have Command reference that id instead of list index.
| int armyIndex = command.getArmyIndex(); | |
| if (armyIndex >= 0 && armyIndex < gameState.getArmies().size()) { | |
| Army army = gameState.getArmies().get(armyIndex); | |
| int targetX = command.getTargetX(); | |
| int targetY = command.getTargetY(); | |
| // Validate target position | |
| if (targetX >= 0 && targetX < gameState.getWidth() && | |
| targetY >= 0 && targetY < gameState.getHeight()) { | |
| army.setX(targetX); | |
| army.setY(targetY); | |
| // Use a stable army identifier instead of treating the value as a list index. | |
| int armyId = command.getArmyIndex(); | |
| Army targetArmy = null; | |
| for (Army army : gameState.getArmies()) { | |
| // Assuming Army exposes a stable identifier via getId() | |
| if (army.getId() == armyId) { | |
| targetArmy = army; | |
| break; | |
| } | |
| } | |
| if (targetArmy != null) { | |
| int targetX = command.getTargetX(); | |
| int targetY = command.getTargetY(); | |
| // Validate target position | |
| if (targetX >= 0 && targetX < gameState.getWidth() && | |
| targetY >= 0 && targetY < gameState.getHeight()) { | |
| targetArmy.setX(targetX); | |
| targetArmy.setY(targetY); |
| import org.springframework.web.bind.annotation.*; | ||
|
|
||
| @RestController | ||
| @CrossOrigin(origins = "*") |
There was a problem hiding this comment.
@crossorigin(origins = "*") enables any website to issue commands to this server from a browser context. Even for a prototype, it’s safer to restrict this to the expected dev origin(s) (e.g., http://localhost:3000 / file:// is not covered) or make it configurable via properties.
| @CrossOrigin(origins = "*") | |
| @CrossOrigin(origins = {"http://localhost:3000", "http://127.0.0.1:3000"}) |
There was a problem hiding this comment.
Restricted CORS to localhost origins only (ports 8080 and 3000) instead of wildcard "*". Commit: 66c55d0
| } | ||
|
|
||
| public List<Army> getArmies() { | ||
| return armies; |
There was a problem hiding this comment.
getArmies exposes the internal representation stored in field armies. The value may be modified after this call to getArmies.
getArmies exposes the internal representation stored in field armies. The value may be modified after this call to getArmies.
| return armies; | |
| return new ArrayList<>(armies); |
There was a problem hiding this comment.
Changed getArmies() to return defensive copy (new ArrayList<>(armies)). Added getArmiesInternal() for internal service modifications. Commit: 66c55d0
|
@copilot address comments |
…port, thread safety, stable army IDs, and CORS restrictions Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
…EADME duplicates Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
|
@copilot add CI checks to ensure that the frontend and backend build and that their unit tests pass; this should run on pull requests into main/develop |
Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
Added GitHub Actions CI workflow that runs on PRs to main/develop branches. The workflow includes: Backend job:
Frontend job:
Both jobs use JDK 17 with Temurin distribution and Maven caching. Added JUnit 5 test dependencies and created smoke tests for core models. All tests passing. Commit: 5de53c7 |
|
@copilot currently there seems to be support for two players but for now this is going to be a single player where enemy Lords will be AI |
Understood. The current implementation uses playerId 1 for the human player and playerId 2 for enemy forces. This structure supports the single-player + AI design - player 1 armies can be controlled via REST commands, while player 2 armies can be controlled by AI logic added to the tick() method in a future update. The existing player distinction will work well for differentiating human vs AI-controlled armies. |
|
@copilot expand unit tests |
Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
|
@copilot review documentation for accuracy and include the core game loop |
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 33 out of 34 changed files in this pull request and generated 2 comments.
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
| // Internal method for direct access to armies list for modifications | ||
| public List<Army> getArmiesInternal() { | ||
| return armies; | ||
| } |
There was a problem hiding this comment.
getArmiesInternal() is a public JavaBean-style getter, so Spring/Jackson will serialize it as an armiesInternal field in API responses (and it also exposes the mutable internal list). If this is intended only for in-service mutations, mark it @JsonIgnore (or rename away from get* / make it non-serializable) and avoid exposing the mutable list on the public API surface.
There was a problem hiding this comment.
Added @JsonIgnore annotation to getArmiesInternal() to prevent it from being serialized in API responses and exposing the mutable internal list. Commit: 753c34a
| public synchronized GameState getState() { | ||
| return gameState; | ||
| } |
There was a problem hiding this comment.
getState() returns the live, mutable gameState instance. Even with synchronized methods, Spring serializes the returned object to JSON after the controller method exits (outside the lock), so another request can mutate the state during serialization, leading to inconsistent responses or ConcurrentModificationException. Prefer returning a snapshot/DTO created under the service lock (deep-copy grid + armies) and have controllers return that snapshot instead of the shared instance.
There was a problem hiding this comment.
Modified getState() to return a deep-copy snapshot of the game state created under the synchronized lock. Added Army copy constructor to preserve IDs during copying. This prevents ConcurrentModificationException during Spring's JSON serialization and ensures thread-safe state access. Added 3 new tests to verify snapshot behavior. Commit: 753c34a
…hitecture Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
Reviewed and enhanced documentation with comprehensive "Core Game Loop" section including: New Sections:
Accuracy Updates:
All builds verified. Commit: 0c3698b |
|
@copilot address comments |
…ate snapshots Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
|
@copilot Include |
…pment Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
Added Maven Wrapper (mvnw) to both backend and frontend projects - Maven installation is now optional. Created Windows batch scripts:
Updated all shell scripts to use
Commit: 0cbc0fc |
|
@copilot This prototype isn’t very engaging yet. From a player’s perspective, I should be able to:
There should also be clear win/lose conditions:
All of the above is out of scope for this PR. Please draft an |
Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
Created comprehensive MVP.md document outlining the path from current prototype to engaging gameplay. The document includes: 6 Core Features:
3-Phase Roadmap:
Each feature includes detailed task breakdowns, backend/frontend changes needed, and test requirements. Estimated 130-185 hours total (4-6 weeks solo). Commit: 6d3d60a |
|
@copilot Excellent, now provide corresponding |
Co-authored-by: dmccoystephenson <21204351+dmccoystephenson@users.noreply.github.com>
Created TICKETS.md with 7 substantial GitHub issue templates optimized for Copilot Coding Agent: Ticket Structure:
Total: 16-20 agent sessions (vs 50+ with granular tickets) Each ticket includes:
Ready to copy directly into GitHub issues with labels ( Commit: 43aba10 |
Original prompt
💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.