Skip to content

Add heartbeat/circuitbreak to lobby and setup #63

Merged
merged 4 commits into from
Apr 12, 2026
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
95 changes: 68 additions & 27 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,35 +1,76 @@
# regicidechess

A [libGDX](https://libgdx.com/) project generated with [gdx-liftoff](https://github.com/libgdx/gdx-liftoff).
# Regicide Chess

This project was generated with a template including simple application launchers and an `ApplicationAdapter` extension that draws libGDX logo.
Regicide Chess is a hybrid chess game for Android that combines classic chess rules with a regicide twist. Built with **LibGDX** and **Firebase**, it supports real-time multiplayer matches, letting players challenge friends or opponents online.

## Platforms
## Project Structure

- `core`: Main module with the application logic shared by all platforms.
- `lwjgl3`: Primary desktop platform using LWJGL3; was called 'desktop' in older docs.
- `android`: Android mobile platform. Needs Android SDK.
The project follows the standard LibGDX multi-module layout:

## Gradle
```
RegicideChess/
├── android/ # Android-specific module
│ └── .../firebase/
│ └── AndroidFirebase.java # Main Firebase interface
├── core/ # Platform-independent game logic
│ ├── database/ # Firebase data handling
│ ├── input/ # Input processing
│ ├── model/ # Game models
│ ├── network/ # Networking utilities
│ ├── screens/ # Game screens
│ │ ├── Lobby/ # Lobby creation/joining
│ │ ├── Game/ # Main gameplay
│ │ ├── MainMenu/ # Main menu
│ │ └── Setup/ # Setup pieces
│ ├── states/ # Game state management
│ └── utils/ # Helper classes
├── assets/ # Sprites, fonts
└── ... # Gradle build files, etc.
```

This project uses [Gradle](https://gradle.org/) to manage dependencies.
The Gradle wrapper was included, so you can run Gradle tasks using `gradlew.bat` or `./gradlew` commands.
Useful Gradle tasks and flags:
- **android/**: Contains Android-specific code, notably `AndroidFirebase.java` which acts as the central hub for all Firebase operations.
- **core/**: All shared game logic, UI screens, input handling, and networking abstractions. This module is independent of Android and can be reused on other platforms.
- **assets/**: Images (piece sprites)

- `--continue`: when using this flag, errors will not stop the tasks from running.
- `--daemon`: thanks to this flag, Gradle daemon will be used to run chosen tasks.
- `--offline`: when using this flag, cached dependency archives will be used.
- `--refresh-dependencies`: this flag forces validation of all dependencies. Useful for snapshot versions.
- `android:lint`: performs Android project validation.
- `build`: builds sources and archives of every project.
- `cleanEclipse`: removes Eclipse project data.
- `cleanIdea`: removes IntelliJ project data.
- `clean`: removes `build` folders, which store compiled classes and built archives.
- `eclipse`: generates Eclipse project data.
- `idea`: generates IntelliJ project data.
- `lwjgl3:jar`: builds application's runnable jar, which can be found at `lwjgl3/build/libs`.
- `lwjgl3:run`: starts the application.
- `test`: runs unit tests (if any).
## Compilation and Execution

Note that most tasks that are not specific to a single project can be run with `name:` prefix, where the `name` should be replaced with the ID of a specific project.
For example, `core:clean` removes `build` folder only from the `core` project.
### For End Users (Play Store / APK)

1. Download the `RegicideChess.apk` from the [Releases Page](https://git.ntnu.no/TDT4240-14/TDT4240/-/releases)
2. Copy it to your Android device and install it. You may need to enable **Install from unknown sources** in your device settings.
3. Tap the app icon to launch.

### For Developers (Build from Source)

#### Prerequisites
- [Android Studio](https://developer.android.com/studio) (with Android SDK and build tools)
- Java 11 or higher

#### Steps
1. Clone the repository:
```bash
git clone https://git.ntnu.no/TDT4240-14/TDT4240.git
```
2. Open the project in **Android Studio**.
3. Wait for Gradle sync to complete (it will automatically download all dependencies, including LibGDX and Firebase SDKs).
4. Build the APK:
- Go to the top menu: `Build``Build Bundle(s) / APK(s)``Build APK(s)`.
5. The debug APK will be generated at:
```
app/build/outputs/apk/debug/
```

#### Running on an Emulator or Device
- Connect your Android device via USB or start an emulator.
- Click the **Run** button in Android Studio.

## Testing Multiplayer with BlueStacks

Since multiplayer requires two devices, we recommend using [BlueStacks](https://www.bluestacks.com) (a free Android emulator for PC) to run multiple instances.

1. Install BlueStacks and open the **Multi‑Instance Manager**.
2. Create two separate instances (e.g., `Instance 1` and `Instance 2`).
3. Launch both instances.
4. Drag the `RegicideChess.apk` file into each BlueStacks window to install the game.
5. On one instance, create a new lobby. On the other, join using the displayed **Game ID**.
6. Play a full match – this simulates two real devices and helps catch multiplayer bugs.
Original file line number Diff line number Diff line change
Expand Up @@ -94,18 +94,27 @@ public void getOpponentBoard(String gameId, boolean localIsWhite, FirebaseAPI.Ca
.addOnFailureListener(e -> onBoard.call(new int[0][0]));
}

private ValueEventListener bothReadyListener;

public void listenForBothSetupReady(String gameId, Runnable onBothReady) {
DatabaseReference ref = db.child("games").child(gameId).child("bothReady");
ValueEventListener listener = new ValueEventListener() {

// Remove existing listener if it exists to prevent duplicates
if (bothReadyListener != null) {
ref.removeEventListener(bothReadyListener);
}

bothReadyListener = new ValueEventListener() {
@Override public void onDataChange(DataSnapshot s) {
Boolean bothReady = s.getValue(Boolean.class);
if (bothReady != null && bothReady) {
ref.removeEventListener(this);
bothReadyListener = null; // Clear reference
onBothReady.run();
}
}
@Override public void onCancelled(DatabaseError e) {}
};
ref.addValueEventListener(listener);
ref.addValueEventListener(bothReadyListener);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -110,14 +110,31 @@ public interface Listener {
private boolean selfConnected = true; // mirrors .info/connected
private boolean forfeitWritten = false; // prevent double-write to gameOver

/**
* @param selfConnectedAtStart Pass the connection state inherited from SetupNetworkHandler
* (via isSelfConnected()). This seeds selfConnected correctly so
* the initial .info/connected = true callback — which Firebase
* always fires immediately on listener attach — is not mistaken
* for a reconnect after a disconnect.
* Pass {@code true} for a normal start; pass the actual state
* when transitioning from SetupScreen.
*/
public GameNetworkHandler(String gameId, Player localPlayer, Listener listener,
Image connectionIcon, Label connectionLabel, FirebaseAPI api) {
Image connectionIcon, Label connectionLabel,
FirebaseAPI api, boolean selfConnectedAtStart) {
this.gameId = gameId;
this.localPlayer = localPlayer;
this.listener = listener;
this.connectionIcon = connectionIcon;
this.connectionLabel = connectionLabel;
this.api = api;
this.selfConnected = selfConnectedAtStart;
}

/** Convenience overload for cases where we know the device is connected (e.g. tests). */
public GameNetworkHandler(String gameId, Player localPlayer, Listener listener,
Image connectionIcon, Label connectionLabel, FirebaseAPI api) {
this(gameId, localPlayer, listener, connectionIcon, connectionLabel, api, true);
}

// ─────────────────────────────────────────────────────────────────────────
Expand Down Expand Up @@ -241,8 +258,12 @@ private void startOpponentMoveListener() {
}

private void startGameOverListener() {
api.listenForGameOver(gameId, reason ->
Gdx.app.postRunnable(() -> listener.onGameOver(reason)));
api.listenForGameOver(gameId, reason -> {
// "cancel:..." is written by SetupNetworkHandler during the setup phase.
// Ignore it — GameScreen only handles "forfeit:..." and "disconnect:...".
if (reason == null || reason.startsWith("cancel:")) return;
Gdx.app.postRunnable(() -> listener.onGameOver(reason));
});
}

private void startHeartbeatAndLatency() {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -106,34 +106,52 @@ public void showOpponentDisconnected() {
overlayWrapper.setVisible(true);
}

/**
* Called when a forfeit/disconnect reason arrives from Firebase.
* reason format: "forfeit:{loserColor}" | "disconnect:{droppedColor}"
*/
public void showForfeitReceived(String reason) {
// reason = "forfeit:{loserColor}" or "disconnect:{disconnectedColor}"
String myColor = localPlayer.isWhite() ? "white" : "black";
String opponentColor = localPlayer.isWhite() ? "black" : "white";
boolean iDisconnected = reason.equals("disconnect:" + myColor);
boolean opponentDisconnected = reason.equals("disconnect:" + opponentColor);
boolean opponentForfeited = reason.equals("forfeit:" + opponentColor);

if (iDisconnected) {
if (reason.equals("disconnect:" + myColor)) {
// I was the one who dropped and couldn't reconnect in time
overlayTitle.setText("You Lost");
overlayBody.setText("You disconnected.");
} else if (opponentDisconnected) {
overlayBody.setText("You disconnected and could not reconnect in time.");
} else if (reason.equals("disconnect:" + opponentColor)) {
// Opponent dropped and didn't reconnect
overlayTitle.setText("You Win!");
overlayBody.setText("Opponent disconnected.");
} else if (opponentForfeited) {
} else if (reason.equals("forfeit:" + opponentColor)) {
// Opponent pressed Forfeit
overlayTitle.setText("You Win!");
overlayBody.setText("Opponent forfeited!");
} else {
// "forfeit:myColor" — I voluntarily forfeited
overlayBody.setText("Opponent forfeited.");
} else if (reason.equals("forfeit:" + myColor)) {
// My own forfeit echoed back from Firebase — isGameOver guard in GameScreen
// should prevent this path, but handle it defensively.
overlayTitle.setText("You Lost");
overlayBody.setText("You forfeited.");
} else {
// Unrecognised reason (e.g. stale "cancel:" from setup — should be filtered
// in GameNetworkHandler, but guard here too).
return;
}
overlayConfirmBtn.setText("Back to Menu");
overlayCancelBtn.setVisible(false);
onOverlayConfirm = listener::onGameOverBack;
overlayWrapper.setVisible(true);
}

/** Shown immediately to the local player after they confirm the forfeit dialog. */
public void showSelfForfeited() {
overlayTitle.setText("You Lost");
overlayBody.setText("You forfeited.");
overlayConfirmBtn.setText("Back to Menu");
overlayCancelBtn.setVisible(false);
onOverlayConfirm = listener::onGameOverBack;
overlayWrapper.setVisible(true);
}

public void showPromotion() {
promotionOverlay.setVisible(true);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -113,9 +113,18 @@ public class GameScreen implements Screen,
// Constructor
// ─────────────────────────────────────────────────────────────────────────

/**
* @param selfConnectedAtStart Connection state at the moment of transition, taken from
* SetupNetworkHandler.isSelfConnected(). Seeding this correctly
* prevents the initial .info/connected = true callback that
* Firebase fires on listener attach from being misread as a
* reconnect after a disconnect, which would cancel the grace timer.
* Use the single-arg overload (defaults to true) when constructing
* GameScreen outside of a setup→game transition (e.g. in tests).
*/
public GameScreen(Game game, SpriteBatch batch,
Board board, Player localPlayer, int boardSize, String gameId,
FirebaseAPI api) {
FirebaseAPI api, boolean selfConnectedAtStart) {
this.game = game;
this.batch = batch;
this.localPlayer = localPlayer;
Expand Down Expand Up @@ -146,7 +155,8 @@ public GameScreen(Game game, SpriteBatch batch,

overlayManager = new GameOverlayManager(stage, skin, localPlayer, this);
networkHandler = new GameNetworkHandler(gameId, localPlayer, this,
connectionIcon, connectionLabel, api);
connectionIcon, connectionLabel, api,
selfConnectedAtStart);
boardRenderer = new GameBoardRenderer(batch, localPlayer, boardSize);
boardRenderer.computeGeometry(V_WIDTH, V_HEIGHT, TOP_BAR_HEIGHT, STATUS_BAR_HEIGHT);

Expand Down Expand Up @@ -390,7 +400,9 @@ public void onForfeitConfirmed() {
stopHeartbeatTimer();
forfeitBtn.setVisible(false);
networkHandler.signalForfeit();
overlayManager.showGameOver(false);
// Show "You forfeited" immediately — the Firebase echo of our own
// forfeit signal must NOT trigger onGameOver again (isGameOver guards it).
overlayManager.showSelfForfeited();
}

@Override
Expand Down
Loading