diff --git a/docs/optional-modules/games.md b/docs/optional-modules/games.md
index 6a9d033..f697c1b 100644
--- a/docs/optional-modules/games.md
+++ b/docs/optional-modules/games.md
@@ -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
+
+ dev.demeng
+ pluginbase-games
+ 1.36.1-SNAPSHOT
+
+```
+
+## 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)` | `` | Binds a resource for automatic cleanup when the state ends. Returns the same object. |
+| `bindModule(TerminableModule)` | `` | 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;
@@ -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) {
@@ -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) {
@@ -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 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 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)` | States from a list, 1-tick update interval. |
+| `ScheduledStateSeries(long, GameState...)` | Custom interval (in ticks), states in order. |
+| `ScheduledStateSeries(long, List)` | 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)` 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 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();
}
}
@@ -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.