In a comment, you asked me how you might use a single thread to manage the timed destruction of multiple entities. First, let's not think about it in terms of using threads. What do we really want to do? We want to perform time-delayed actions.
How might we do this? Well...
Introduction to Event Loop Scheduling
We can create a scheduler to perform actions (or respond to events) at a specific time. These might be one-time actions, or recurring actions that repeat at a fixed interval. You will likely end up with many such actions in your game. How might we implement this? Well, we don't actually have to; it's been done many times, and done quite well. But the jist of it is this:
- Create a queue to hold scheduled work items. This should be a priority queue, sorted such that the work items occurring nearest in time appear closest to the front of the queue.
- Kick off a processing thread that waits on a queue.
- Various game components schedule work items by inserting them into the queue (in a thread-safe manner). Upon insertion, they signal the processing thread to wake up and check the queue.
- The processing thread, upon waking up, checks to see if there are work items in the queue. If it finds one, it looks at its completion time and compares it against the current game time.
- If it's time to run the work item, it does so. Repeat step 4 for the next item in the queue, if there is one.
- If it's not time to run the work item, the thread puts itself to sleep until
dueTime - currentTime
has elapsed.
- Alternatively, if the queue is empty, sleep indefinitely; we'll get woken up when the next work item is scheduled.
This kind of scheduler is known as an event loop: it runs in a "dequeue, run, wait, repeat" loop. A good example can be found in RxJava. You could use it like this:
import io.reactivex.Scheduler;
import io.reactivex.disposables.SerialDisposable;
public final class GameSchedulers {
private static final Scheduler EVENT_LOOP =
io.reactivex.schedulers.Schedulers.single();
public static Scheduler eventLoop() {
return EVENT_LOOP;
}
}
public abstract class Entity implements Collidable {
private final SerialDisposable scheduledDestruction = new SerialDisposable();
private volatile boolean isDestroyed;
public void destroyNow() {
this.isDestroyed = true;
this.scheduledDestruction.dispose();
}
public void destroyAfter(long delay, TimeUnit unit) {
scheduledDestruction.set(
GameSchedulers.eventLoop()
.scheduleDirect(this::destroyNow, delay, unit)
);
}
/* (rest of class omitted) */
}
To schedule an entity to be destroyed after 4 seconds, you would call entity.destroyAfter(4L, TimeUnit.SECONDS)
. That call would schedule the destroyNow()
method to be called after a 4 second delay. The scheduled action is stored in a SerialDisposable
, which can be used to 'dispose' of some object. In this case, we use it to track the scheduled destruction action, and the 'disposal' amounts to a cancellation of that action. In the example above, this serves two purposes:
- If the entity gets destroyed by some other means, e.g., the player shoots and destroys a missile, you can simply call
destroyNow()
, which in turn cancels any previously scheduled destruction (which would now be redundant).
- If you want to change the destruction time, you can just call
destroyAfter
a second time, and if the originally scheduled action hasn't occurred yet, it will be canceled and prevented from running.
Caveats
Games are an interesting case, specifically with regards to time. Consider:
Time in a game does not necessarily proceed at a constant rate. When a game experiences poor performance, the flow of time generally slows accordingly. It's also (usually) possible to pause time.
A game may rely on more than one 'clock'. The player may pause gameplay, effectively freezing the 'game time' clock. Meanwhile, the player may still interact with game menus and options screens, which might be animated according to 'real' time (e.g. system time).
Game time usually flows in one direction, while system time does not. Most PCs these days keep their system clock synchronized with a time server, so the system time is constantly being corrected for 'drift'. Thus, it is not unusual for the system clock to jump backward in time.
Because system time tends to fluctuate slightly, it's not smooth. However, we're at the system scheduler's mercy when it comes to running our code. If we set a goal of advancing game time by one 'tick' 60 times a second (to target 60fps), we need to understand that our 'ticks' will almost never happening exactly when we want them to. Thus, we ought to interpolate: if our tick occurred slightly before or after we expected it, we should advance our game time by slightly less or more than one 'tick'.
These kinds of considerations may prevent you from using a third-party scheduler. You might still use one early in development, but eventually you'll need one that proceeds according to game time and not system time. RxJava actually has a scheduler implementation called TestScheduler
that is controlled by an external clock. However, it is not thread safe, and relies on an external actor manually advancing time, but you could use it as a model for your own scheduler.