Skip to content
Merged
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
233 changes: 140 additions & 93 deletions docs/optional-modules/games.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,71 @@ description: Game state management system for phased minigames.

# Games

## GameState Class
The `pluginbase-games` module provides a state machine for minigame phases. Add it as a dependency alongside `pluginbase-core`:

```xml
<dependency>
<groupId>dev.demeng</groupId>
<artifactId>pluginbase-games</artifactId>
<version>1.36.1-SNAPSHOT</version>
</dependency>
```

## GameState

`GameState` is an abstract class representing a single phase of a minigame (lobby, active round, post-game, etc.). Each state has a lifecycle driven by four abstract methods:

| Method | Visibility | Description |
|---|---|---|
| `onStart()` | `protected` | Called once when `start()` is invoked. |
| `onUpdate()` | `protected` | Called on each tick while the state is active. |
| `onEnd()` | `protected` | Called once when `end()` is invoked. Bound resources are closed before this runs. |
| `getDuration()` | `protected` | Returns the state duration in milliseconds. |

Public control methods:

| Method | Returns | Description |
|---|---|---|
| `start()` | `void` | Starts the state. No-op if already started or ended. |
| `update()` | `void` | Ticks the state. Calls `end()` automatically if `isReadyToEnd()` returns true, otherwise calls `onUpdate()`. |
| `end()` | `void` | Ends the state: closes bound resources, then calls `onEnd()`. No-op if not started or already ended. |
| `isStarted()` | `boolean` | Whether `start()` has been called. |
| `isEnded()` | `boolean` | Whether `end()` has been called. |
| `getStartTime()` | `long` | Epoch millis when the state started, or 0 if not started. |
| `getRemainingDuration()` | `long` | Milliseconds remaining based on `getDuration()`, minimum 0. |
| `bind(AutoCloseable)` | `<T>` | Binds a resource for automatic cleanup when the state ends. Returns the same object. |
| `bindModule(TerminableModule)` | `<T>` | Binds a `TerminableModule` for automatic cleanup. Returns the same module. |

### Duration and end conditions

`GameState` represents a single phase of a minigame (e.g., lobby, active game, post-game).
`isReadyToEnd()` is a `protected` method that `update()` calls each tick to decide whether to end the state. The default implementation returns true when `getRemainingDuration() <= 0`, which means the elapsed time since start has exceeded `getDuration()`.

If `getDuration()` returns 0, the state will end on its first `update()` call. There is no built-in "infinite duration" sentinel. To create a state that runs indefinitely until manually ended, override `isReadyToEnd()`:

```java
import dev.demeng.pluginbase.games.GameState;
@Override
protected boolean isReadyToEnd() {
return false;
}

@Override
protected long getDuration() {
return 0;
}
```

For custom end conditions (player count, score threshold, etc.), override `isReadyToEnd()`:

```java
@Override
protected boolean isReadyToEnd() {
return alivePlayers.size() <= 1;
}
```

### Minimal example

```java
public class ActiveGameState extends GameState {

private final Arena arena;
Expand All @@ -23,7 +81,6 @@ public class ActiveGameState extends GameState {

@Override
protected void onStart() {
// Called when state begins
players.forEach(uuid -> {
Player player = Bukkit.getPlayer(uuid);
if (player != null) {
Expand All @@ -34,14 +91,10 @@ public class ActiveGameState extends GameState {
}

@Override
protected void onUpdate() {
// Called periodically during the state
// Check win conditions, etc.
}
protected void onUpdate() {}

@Override
protected void onEnd() {
// Called when state ends
players.forEach(uuid -> {
Player player = Bukkit.getPlayer(uuid);
if (player != null) {
Expand All @@ -52,58 +105,79 @@ public class ActiveGameState extends GameState {

@Override
protected long getDuration() {
// Duration in milliseconds (0 for infinite)
return 300000; // 5 minutes
return 300_000; // 5 minutes
}
}
```

## Using GameState
## Binding resources to a state

`bind(AutoCloseable)` registers a resource (event subscription, scheduled task, etc.) for automatic cleanup when the state ends. All bound resources are closed before `onEnd()` is called.

`GameState` does **not** implement `TerminableConsumer`, so you cannot use `.bindWith(this)` inside a state. Instead, wrap the resource with `bind()`:

```java
public class SpleefPlugin extends BasePlugin {
public class ActiveGameState extends GameState {

private GameState currentState;
private final Set<UUID> players = new HashSet<>();
@Override
protected void onStart() {
bind(Events.subscribe(BlockBreakEvent.class)
.handler(this::handleBreak));

bind(Schedulers.sync().runRepeating(() -> {
updateScoreboard();
}, 0L, 20L));
}

@Override
protected void enable() {
// Start in lobby state
currentState = new LobbyState(players);
currentState.start();
protected void onUpdate() {}

// Update state periodically
Schedulers.sync().runRepeating(() -> {
if (currentState != null && currentState.isStarted() && !currentState.isEnded()) {
currentState.update();
}
}, 0L, 20L).bindWith(this);
@Override
protected void onEnd() {}

// Join command
Lamp<BukkitCommandActor> handler = createCommandHandler();
handler.register(new SpleefCommands(this));
@Override
protected long getDuration() {
return 300_000;
}

public void startGame() {
// End current state
if (currentState != null) {
currentState.end();
}

// Start active game state
currentState = new ActiveGameState(arena, players);
currentState.start();
private void handleBreak(BlockBreakEvent event) {
// ...
}
}
```

public void endGame() {
// End active state
if (currentState != null) {
currentState.end();
}
## ScheduledStateSeries

// Return to lobby
currentState = new LobbyState(players);
currentState.start();
`ScheduledStateSeries` extends `GameState` and runs a sequence of states one after another. Each child state's `update()` is called on a fixed tick interval. When a child state's `isReadyToEnd()` returns true, it is ended and the next state starts.

Constructors:

| Constructor | Description |
|---|---|
| `ScheduledStateSeries(GameState...)` | States in order, 1-tick update interval. |
| `ScheduledStateSeries(List<GameState>)` | States from a list, 1-tick update interval. |
| `ScheduledStateSeries(long, GameState...)` | Custom interval (in ticks), states in order. |
| `ScheduledStateSeries(long, List<GameState>)` | Custom interval (in ticks), states from a list. |
| `ScheduledStateSeries()` | Empty series, 1-tick interval. |
| `ScheduledStateSeries(long)` | Empty series, custom interval. |

The `addNext(GameState...)` and `addNext(List<GameState>)` methods insert states immediately after the current one, pushing later states back. Useful for dynamic phase injection (overtime, tiebreaker, etc.).

### Full example

```java
public class SpleefPlugin extends BasePlugin {

private final Set<UUID> players = new HashSet<>();

@Override
protected void enable() {
LobbyState lobby = new LobbyState(players);
ActiveGameState active = new ActiveGameState(arena, players);
PostGameState postGame = new PostGameState();

ScheduledStateSeries series = new ScheduledStateSeries(lobby, active, postGame);
series.start();
}
}

Expand All @@ -121,74 +195,47 @@ public class LobbyState extends GameState {
}

@Override
protected void onUpdate() {
// Check if enough players to start
}
protected void onUpdate() {}

@Override
protected void onEnd() {
// Cleanup
}
protected void onEnd() {}

@Override
protected long getDuration() {
return 0; // Infinite - manual transition
return 30_000; // 30 second lobby countdown
}
}
```

## Binding Resources
### Manual state management (without ScheduledStateSeries)

Bind resources to the state so they're automatically cleaned up when the state ends:
If you need full control over transitions, drive `update()` yourself:

```java
public class ActiveGameState extends GameState {
public class SpleefPlugin extends BasePlugin {

private GameState currentState;

@Override
protected void onStart() {
// Events bound to this state
Events.subscribe(BlockBreakEvent.class)
.handler(this::handleBreak)
.bindWith(this);
protected void enable() {
currentState = new LobbyState(players);
currentState.start();

// Tasks bound to this state
Schedulers.sync().runRepeating(() -> {
updateScoreboard();
if (currentState != null && currentState.isStarted() && !currentState.isEnded()) {
currentState.update();
}
}, 0L, 20L).bindWith(this);
}

@Override
protected void onEnd() {
// All bound resources are automatically cleaned up
public void transition(GameState next) {
if (currentState != null) {
currentState.end();
}
currentState = next;
currentState.start();
}

// ...
}
```

## State Lifecycle

```java
GameState state = new MyGameState();

// Start the state
state.start(); // Calls onStart()
state.isStarted(); // true

// Update state
state.update(); // Calls onUpdate()

// Check if ready to end
if (state.isReadyToEnd()) {
state.end(); // Calls onEnd()
}

state.isEnded(); // true
```

## Benefits

* **Lifecycle management** - Clear start, update, end phases
* **Automatic cleanup** - Bound resources cleaned when state ends
* **Duration control** - States can have fixed durations
* **Resource binding** - Events and tasks automatically terminate with state
Note: `.bindWith(this)` is valid here because `BasePlugin` implements `TerminableConsumer`. Inside a `GameState` subclass, use `bind()` instead.
Loading