Skip to content

54 clean up architecture and fix bugs #55

Merged
merged 11 commits into from
Apr 20, 2026
149 changes: 90 additions & 59 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,59 +1,90 @@
# BeatBattle 🎵

A multiplayer music quiz game for Android where players compete to identify songs from short audio clips.
A real-time multiplayer music quiz game for Android where players compete to identify songs from short audio previews.

**Course:** TDT4240 – Software Architecture, NTNU
**Group:** 07

## How it works

One player hosts a session and receives a 4-character game PIN. Other players join using that PIN. The host fetches songs from the Deezer API and distributes questions to all players via Firebase. Each round plays a 30-second audio preview and presents four multiple-choice options. Faster correct answers award more points. After each round, a leaderboard shows scores and the round delta for every player. The host controls round progression; joiners stay synchronized via persistent Firebase listeners.

## Tech Stack

- **Platform:** Android (API 26+)
- **Framework:** LibGDX 1.12.x
- **Backend:** Firebase Firestore (real-time sync)
- **Music API:** Deezer API (30s previews)
- **Language:** Java
| Component | Technology |
|-----------|------------|
| Framework | LibGDX 1.14.0 |
| Platforms | Android (API 26+) · Desktop (LWJGL3, no real multiplayer) |
| Backend | Firebase Firestore (real-time sync) |
| Music API | Deezer API (top-chart 30s previews) |
| Language | Java 17 |
| Build | Gradle |

## Architecture

The project follows a layered MVC architecture with ECS for entity management:
The project applies three patterns simultaneously:

**MVC** — Views render state and forward input to Controllers. Controllers orchestrate game logic and Firebase calls. Domain models hold no UI or infrastructure dependencies.

**Client-Server** — All Firebase access flows through the `FirebaseGateway` interface. The Android implementation hits Firestore; a `NoOpFirebaseGateway` lets the desktop build run without Firebase. No view or domain class imports the Firebase SDK.

**ECS** — Round timing and audio playback are managed via a lightweight Entity-Component-System. `TimerComponent` and `AudioComponent` hold data; `RoundSystem` and `AudioSystem` contain the logic; `Engine` drives the update loop. `RoundManager` wraps ECS details so controllers stay decoupled from it.

### Dependency flow

```
User Interface → Screens & reusable UI components
Game Logic → Session, round, scoring, state management
Domain → Player, Song, GameSession, Submission, etc.
Infrastructure → Firebase gateway, audio provider, adapters
Views → Controllers → Domain ← FirebaseGateway / Services
ECS Engine
```

Dependencies flow one-directionally: **UI → Game Logic → Domain ← Infrastructure**

## Project Structure

```
BeatBattle/
├── android/ # Android launcher & manifest
├── core/ # Shared game code
│ └── src/main/java/group07/beatbattle/
│ ├── ui/
│ │ ├── screens/ # JoinGameScreen, LobbyScreen, QuizScreen, etc.
│ │ └── components/ # AnswerOptionBtn, TimerView, PlayerList
│ ├── logic/
│ │ ├── session/ # Session creation & joining
│ │ ├── round/ # Round flow & timing
│ │ ├── scoring/ # Score calculation & streaks
│ │ ├── state/ # StateManager & concrete states
│ │ └── api/ # SessionGateway, LeaderboardRepository, AudioProvider
│ ├── domain/
│ │ └── model/ # Player, Song, Question, Submission, etc.
│ └── infra/
│ ├── persistence/ # Firebase Firestore implementation
│ ├── sync/ # Real-time listeners
│ ├── audio/ # Deezer / local audio playback
│ └── adapters/ # Interface implementations
├── assets/ # Game assets (sprites, sounds, fonts)
├── build.gradle # Root build config
└── settings.gradle
├── android/ # Android launcher, Firebase & audio implementations
│ └── src/main/java/.../android/
│ ├── AndroidLauncher.java
│ ├── AndroidAudioPlayer.java
│ ├── DeezerMusicService.java
│ └── firebase/
│ ├── AndroidFirebaseGateway.java
│ └── FirestoreSessionRepository.java
├── lwjgl3/ # Desktop launcher (stub services, no multiplayer)
├── core/ # All platform-agnostic game code
│ └── src/main/java/.../beatbattle/
│ ├── BeatBattle.java # Root Game class; owns session state & shared fonts
│ ├── controller/
│ │ ├── LobbyController.java # Session creation, joining, game start
│ │ ├── RoundController.java # Per-round lifecycle & answer scoring
│ │ ├── LeaderboardController.java
│ │ └── SettingsController.java
│ ├── model/
│ │ ├── domain/ # GameSession, Player, Question, Song, GameRules, …
│ │ ├── dto/ # SessionCreationResult, SessionJoinResult
│ │ ├── score/ # ScoreCalculator
│ │ └── services/ # LobbyService, MusicService
│ ├── ecs/
│ │ ├── Engine.java
│ │ ├── components/ # TimerComponent, AudioComponent
│ │ ├── systems/ # RoundSystem, AudioSystem
│ │ └── entities/ # Entity, RoundFactory
│ ├── states/ # StateManager + Start/Lobby/InRound/Leaderboard/GameOver states
│ ├── view/ # One ScreenAdapter per state
│ ├── ui/ # Reusable Scene2D components & dialogs
│ ├── firebase/ # FirebaseGateway interface + NoOpFirebaseGateway
│ └── audio/ # AudioPlayer interface
└── assets/ # Fonts, images
```

## Scoring

- **Wrong answer:** 0 points
- **Correct within first 3 seconds:** 1 000 points
- **Correct after grace period:** linear decay from 1 000 → 300 points over the remaining 27 seconds

Round duration is 30 seconds (`GameRules.ROUND_DURATION_SECONDS`).

## Getting Started

### Prerequisites
Expand All @@ -67,45 +98,45 @@ BeatBattle/
1. Clone the repo:
```bash
git clone git@git.ntnu.no:milospi/TDT4240gr07-BeatBattle.git
cd BeatBattle
cd TDT4240gr07-BeatBattle
```

2. Add your `google-services.json` to `android/` (get from Firebase Console — not committed to git)
2. Add your `google-services.json` to `android/` (from Firebase Console — not committed to git)

3. Open in Android Studio or build from terminal:
3. Build and install on a device or emulator:
```bash
./gradlew android:assembleDebug
```

4. Run on emulator or device:
```bash
./gradlew android:installDebug
```

### Desktop (no multiplayer)

```bash
./gradlew lwjgl3:run
```

The desktop build uses stub implementations (`NoOpFirebaseGateway`, `MockMusicService`) and is useful for UI development only.

## Branching Strategy

| Branch | Purpose |
|--------|---------|
| `main` | Stable, releasable code only |
| `dev` | Integration branch — PRs merge here |
| `feature/<name>` | New features (e.g. `feature/lobby-screen`) |
| `bugfix/<name>` | Bug fixes |


| `feature/<issue>-<name>` | New features |
| `bugfix/<issue>-<name>` | Bug fixes |

**Rules:**
- Never push directly to `main` or `dev`
- Never push directly to `main`
- All changes go through Pull Requests with at least 1 reviewer
- Keep feature branches short-lived
- Branch names should reference the related issue number

## Team

| Name | Role |
|------|------|
| Anja Pedersen | |
| Julie Hoel | |
| Karya Vindstad | |
| Kristoffer Welle | |
| Milos Pilipovic | |
| Mostafa Saleh | |
| Oda Aasen Kjerstad | |
| Name |
|------|
| Anja Pedersen |
| Julie Hoel |
| Karya Vindstad |
| Kristoffer Welle |
| Milos Pilipovic |
| Mostafa Saleh |
| Oda Aasen Kjerstad |
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@

import com.badlogic.gdx.Gdx;

import group07.beatbattle.model.Song;
import group07.beatbattle.model.domain.Song;
import group07.beatbattle.model.services.MusicService;
import group07.beatbattle.model.services.MusicServiceCallback;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,9 @@ public void listenToRoundAnswerCount(String sessionId, int roundIndex, AnswerCou
public void clearAnswers(String sessionId, SimpleCallback callback) {
sessionRepository.clearAnswers(sessionId, callback);
}

@Override
public void stopAllListeners() {
sessionRepository.stopAllListeners();
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@
import java.util.Map;

import group07.beatbattle.firebase.FirebaseGateway;
import group07.beatbattle.model.Player;
import group07.beatbattle.model.domain.Player;

/**
* Direct Firestore data-access layer for all session-related operations.
Expand All @@ -37,6 +37,7 @@ public class FirestoreSessionRepository {

private final FirebaseFirestore firestore;
private ListenerRegistration sessionStateRegistration;
private final List<ListenerRegistration> activeListeners = new ArrayList<>();

public FirestoreSessionRepository(FirebaseFirestore firestore) {
this.firestore = firestore;
Expand Down Expand Up @@ -144,7 +145,7 @@ public ListenerRegistration listenToPlayers(
String sessionId,
FirebaseGateway.PlayersListenerCallback callback
) {
return firestore.collection(SESSIONS)
ListenerRegistration reg = firestore.collection(SESSIONS)
.document(sessionId)
.collection(PLAYERS)
.addSnapshotListener((snapshot, error) -> {
Expand Down Expand Up @@ -180,6 +181,8 @@ public ListenerRegistration listenToPlayers(

callback.onPlayersChanged(players);
});
activeListeners.add(reg);
return reg;
}

public void fetchPlayers(
Expand Down Expand Up @@ -376,10 +379,19 @@ public void updateSessionState(
public void stopListeningToSessionState() {
if (sessionStateRegistration != null) {
sessionStateRegistration.remove();
activeListeners.remove(sessionStateRegistration);
sessionStateRegistration = null;
}
}

public void stopAllListeners() {
for (ListenerRegistration reg : activeListeners) {
if (reg != null) reg.remove();
}
activeListeners.clear();
sessionStateRegistration = null;
}

public void listenToSessionState(
String sessionId,
FirebaseGateway.SessionStateListener listener
Expand All @@ -388,6 +400,7 @@ public void listenToSessionState(
// listeners from prior screens/rounds don't accumulate and fire stale events.
if (sessionStateRegistration != null) {
sessionStateRegistration.remove();
activeListeners.remove(sessionStateRegistration);
sessionStateRegistration = null;
}
sessionStateRegistration = firestore.collection(SESSIONS)
Expand Down Expand Up @@ -417,6 +430,7 @@ public void listenToSessionState(
);
}
});
activeListeners.add(sessionStateRegistration);
}

public void submitAnswer(
Expand All @@ -443,7 +457,7 @@ public void listenToRoundAnswerCount(
int roundIndex,
FirebaseGateway.AnswerCountCallback callback
) {
firestore.collection(SESSIONS)
ListenerRegistration reg = firestore.collection(SESSIONS)
.document(sessionId)
.collection("Answers")
.whereEqualTo("roundIndex", roundIndex)
Expand All @@ -455,6 +469,7 @@ public void listenToRoundAnswerCount(
int count = snapshot != null ? snapshot.size() : 0;
callback.onCountChanged(count);
});
activeListeners.add(reg);
}

public void clearAnswers(String sessionId, FirebaseGateway.SimpleCallback callback) {
Expand Down
4 changes: 3 additions & 1 deletion core/src/main/java/group07/beatbattle/BeatBattle.java
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@
import group07.beatbattle.controller.LobbyController;
import group07.beatbattle.utils.Strings;
import group07.beatbattle.ecs.Engine;
import group07.beatbattle.model.GameSession;
import group07.beatbattle.model.domain.GameSession;
import group07.beatbattle.model.services.MusicService;
import group07.beatbattle.states.StartState;
import group07.beatbattle.states.StateManager;
Expand Down Expand Up @@ -136,6 +136,8 @@ public void dispose() {
if (orbitronFont != null) orbitronFont.dispose();
if (oswaldFont != null) oswaldFont.dispose();

if (firebaseGateway != null) firebaseGateway.stopAllListeners();

BackButton.disposeSharedResources();
JoinCreateButton.disposeSharedResources();
InputFieldStyles.dispose();
Expand Down
Loading
Loading