diff --git a/fxgl-entity/src/main/java/com/almasb/fxgl/entity/component/TimerActionComponent.java b/fxgl-entity/src/main/java/com/almasb/fxgl/entity/component/TimerActionComponent.java
new file mode 100644
index 0000000000..a3778dfaf0
--- /dev/null
+++ b/fxgl-entity/src/main/java/com/almasb/fxgl/entity/component/TimerActionComponent.java
@@ -0,0 +1,87 @@
+/*
+ * FXGL - JavaFX Game Library. The MIT License (MIT).
+ * Copyright (c) AlmasB (almaslvl@gmail.com).
+ * See LICENSE for details.
+ */
+
+package com.almasb.fxgl.entity.component;
+
+import com.almasb.fxgl.time.Timer;
+import com.almasb.fxgl.time.TimerAction;
+import javafx.beans.property.ReadOnlyBooleanProperty;
+import javafx.util.Duration;
+
+/**
+ * Component to schedule the execution of actions.
+ * The timer's time per frame (TPF) is tied to the entity's TPF.
+ * The entity's TPF can be modified by {@link com.almasb.fxgl.entity.components.TimeComponent}.
+ *
+ * @implNote - A wrapper around a Timer class, as a component.
+ *
+ * @author Michael Pearson (https://github.com/michqql/)
+ */
+public final class TimerActionComponent extends Component {
+
+ private final Timer timer = new Timer();
+
+ @Override
+ public void onUpdate(double tpf) {
+ this.timer.update(tpf);
+ }
+
+ /**
+ * The Runnable [action] will be scheduled to start at given [interval].
+ * The action will start for the first time after given interval.
+ * The action will be scheduled unlimited number of times unless user cancels it
+ * via the returned action object.
+ *
+ * @return timer action
+ */
+ public TimerAction runAtInterval(Runnable action, Duration interval) {
+ return timer.runAtInterval(action, interval);
+ }
+
+ /**
+ * The Runnable [action] will be scheduled to start at given [interval].
+ * The action will start for the first time after given interval.
+ * The action will be scheduled [limit] number of times unless user cancels it
+ * via the returned action object.
+ *
+ * @return timer action
+ */
+ public TimerAction runAtInterval(Runnable action, Duration interval, int limit) {
+ return timer.runAtInterval(action, interval, limit);
+ }
+
+ /**
+ * The Runnable [action] will be scheduled to start at given [interval].
+ * The Runnable action will be scheduled IFF
+ * [whileCondition] is initially true.
+ * The action will start for the first time after given interval.
+ * The action will be removed from schedule when [whileCondition] becomes "false".
+ * Note: you must retain the reference to the [whileCondition] property to avoid it being
+ * garbage collected, otherwise the [action] may never stop.
+ *
+ * @return timer action
+ */
+ public TimerAction runAtIntervalWhile(Runnable action, Duration interval, ReadOnlyBooleanProperty whileCondition) {
+ return timer.runAtIntervalWhile(action, interval, whileCondition);
+ }
+
+ /**
+ * The Runnable [action] will be scheduled to run once after given [delay].
+ * The action can be cancelled before it starts via the returned action object.
+ *
+ * @return timer action
+ */
+ public TimerAction runOnceAfter(Runnable action, Duration delay) {
+ return timer.runOnceAfter(action, delay);
+ }
+
+ /**
+ * Remove all scheduled actions.
+ */
+ public void clear() {
+ timer.clear();
+ }
+}
diff --git a/fxgl-entity/src/test/java/com/almasb/fxgl/entity/component/TimerActionComponentTest.java b/fxgl-entity/src/test/java/com/almasb/fxgl/entity/component/TimerActionComponentTest.java
new file mode 100644
index 0000000000..94a180de0d
--- /dev/null
+++ b/fxgl-entity/src/test/java/com/almasb/fxgl/entity/component/TimerActionComponentTest.java
@@ -0,0 +1,267 @@
+/*
+ * FXGL - JavaFX Game Library. The MIT License (MIT).
+ * Copyright (c) AlmasB (almaslvl@gmail.com).
+ * See LICENSE for details.
+ */
+
+package com.almasb.fxgl.entity.component;
+
+import com.almasb.fxgl.time.TimerAction;
+import javafx.beans.binding.BooleanBinding;
+import javafx.beans.property.*;
+import javafx.util.Duration;
+import org.junit.jupiter.api.Test;
+
+import static org.junit.jupiter.api.Assertions.*;
+
+/**
+ * Tests for the {@link TimerActionComponent} class.
+ *
+ * @author Michael Pearson (https://github.com/michqql/)
+ */
+public class TimerActionComponentTest {
+
+ /**
+ * Tests that the action is ran multiple times as the interval elapses.
+ * Tests the method {@link TimerActionComponent#runAtInterval(Runnable, Duration, int) runAtInterval}.
+ */
+ @Test
+ public void testRunAtInterval() {
+ final TimerActionComponent timerActionComponent = new TimerActionComponent();
+ final IntegerProperty executionCounter = new SimpleIntegerProperty();
+ final Runnable action = () -> executionCounter.set(executionCounter.get() + 1);
+
+ timerActionComponent.runAtInterval(action, Duration.seconds(0.5f));
+
+ assertEquals(0, executionCounter.get());
+ timerActionComponent.onUpdate(0.5f);
+ assertEquals(1, executionCounter.get());
+ timerActionComponent.onUpdate(0.4f);
+ assertEquals(1, executionCounter.get());
+ timerActionComponent.onUpdate(0.2f);
+ assertEquals(2, executionCounter.get());
+ }
+
+ /**
+ * Tests that the repeating action will not be executed after being cancelled.
+ */
+ @Test
+ public void testRunAtIntervalThenCancel() {
+ final TimerActionComponent timerActionComponent = new TimerActionComponent();
+ final IntegerProperty executionCounter = new SimpleIntegerProperty();
+ final Runnable action = () -> executionCounter.set(executionCounter.get() + 1);
+
+ TimerAction timerAction = timerActionComponent.runAtInterval(action, Duration.seconds(0.5f));
+
+ assertEquals(0, executionCounter.get());
+ timerActionComponent.onUpdate(0.5f);
+ assertEquals(1, executionCounter.get());
+
+ timerAction.expire();
+
+ timerActionComponent.onUpdate(0.5f);
+ assertEquals(1, executionCounter.get());
+ }
+
+ /**
+ * Tests that the action can be executed multiple times.
+ */
+ @Test
+ public void testRunAtIntervalInLoop() {
+ final TimerActionComponent timerActionComponent = new TimerActionComponent();
+ final IntegerProperty executionCounter = new SimpleIntegerProperty();
+ final Runnable action = () -> executionCounter.set(executionCounter.get() + 1);
+
+ timerActionComponent.runAtInterval(action, Duration.seconds(0.5f));
+
+ for(int i = 0; i < 10; ++i) {
+ timerActionComponent.onUpdate(0.5f);
+ assertEquals(i + 1, executionCounter.get());
+ }
+ }
+
+ /**
+ * Tests that the action expires with the limit and will not execute again.
+ */
+ @Test
+ public void testRunAtIntervalWithLimit() {
+ final TimerActionComponent timerActionComponent = new TimerActionComponent();
+ final IntegerProperty executionCounter = new SimpleIntegerProperty();
+ final Runnable action = () -> executionCounter.set(executionCounter.get() + 1);
+
+ timerActionComponent.runAtInterval(action, Duration.seconds(0.5f), 4);
+
+ for(int i = 0; i < 10; ++i) {
+ timerActionComponent.onUpdate(0.5f);
+ }
+
+ assertEquals(4, executionCounter.get());
+ }
+
+ /**
+ * Tests that the action expires when the condition becomes false.
+ */
+ @Test
+ public void testRunAtIntervalConditional() {
+ final TimerActionComponent timerActionComponent = new TimerActionComponent();
+ final IntegerProperty executionCounter = new SimpleIntegerProperty();
+ final Runnable action = () -> executionCounter.set(executionCounter.get() + 1);
+
+ IntegerProperty iterationCount = new SimpleIntegerProperty();
+ BooleanBinding condition = iterationCount.lessThan(5);
+
+ BooleanProperty conditionProperty = new SimpleBooleanProperty();
+ conditionProperty.bind(condition);
+
+ timerActionComponent.runAtIntervalWhile(action, Duration.seconds(0.25f), conditionProperty);
+
+ for(int i = 0; i < 10; ++i) {
+ timerActionComponent.onUpdate(0.5f);
+ iterationCount.set(iterationCount.get() + 1);
+ }
+
+ assertEquals(5, executionCounter.get());
+ }
+
+ /**
+ * Tests that the conditional action can be cancelled by the user.
+ */
+ @Test
+ public void testRunAtIntervalConditionalCancelledEarly() {
+ final TimerActionComponent timerActionComponent = new TimerActionComponent();
+ final IntegerProperty executionCounter = new SimpleIntegerProperty();
+ final Runnable action = () -> executionCounter.set(executionCounter.get() + 1);
+
+ IntegerProperty iterationCount = new SimpleIntegerProperty();
+ BooleanBinding condition = iterationCount.lessThan(5);
+
+ BooleanProperty conditionProperty = new SimpleBooleanProperty();
+ conditionProperty.bind(condition);
+
+ TimerAction timerAction = timerActionComponent.runAtIntervalWhile(action, Duration.seconds(0.25f), conditionProperty);
+
+ for(int i = 0; i < 10; ++i) {
+ timerActionComponent.onUpdate(0.5f);
+ iterationCount.set(iterationCount.get() + 1);
+
+ /* Cancel on the 3rd iteration */
+ if(i == 2)
+ timerAction.expire();
+ }
+
+ assertEquals(3, executionCounter.get());
+ }
+
+ /**
+ * Tests that the action is ran exactly once after the delay has elapsed
+ * when using {@link TimerActionComponent#runOnceAfter(Runnable, Duration) runOnceAfter}.
+ */
+ @Test
+ public void testRunOnceAfter() {
+ final TimerActionComponent timerActionComponent = new TimerActionComponent();
+ final IntegerProperty executionCounter = new SimpleIntegerProperty();
+ final Runnable action = () -> executionCounter.set(executionCounter.get() + 1);
+
+ timerActionComponent.runOnceAfter(action, Duration.seconds(1));
+
+ assertEquals(0, executionCounter.get());
+ timerActionComponent.onUpdate(1.0f);
+ assertEquals(1, executionCounter.get());
+ timerActionComponent.onUpdate(1.0f);
+ assertEquals(1, executionCounter.get());
+ }
+
+ /**
+ * Tests that the action is only ran after the delay has elapsed,
+ * when the component has already seen time elapse.
+ * Tests the method {@link TimerActionComponent#runOnceAfter(Runnable, Duration) runOnceAfter}.
+ */
+ @Test
+ public void testRunOnceAfterWithStartingTime() {
+ final TimerActionComponent timerActionComponent = new TimerActionComponent();
+ final IntegerProperty executionCounter = new SimpleIntegerProperty();
+ final Runnable action = () -> executionCounter.set(executionCounter.get() + 1);
+
+ timerActionComponent.onUpdate(2.0f);
+ timerActionComponent.runOnceAfter(action, Duration.seconds(1));
+
+ assertEquals(0, executionCounter.get());
+ timerActionComponent.onUpdate(1.0f);
+ assertEquals(1, executionCounter.get());
+ }
+
+ /**
+ * Tests that the action is not ran if not enough time elapses.
+ * Tests the method {@link TimerActionComponent#runOnceAfter(Runnable, Duration) runOnceAfter}.
+ */
+ @Test
+ public void testRunOnceNotElapsed() {
+ final TimerActionComponent timerActionComponent = new TimerActionComponent();
+ final IntegerProperty executionCounter = new SimpleIntegerProperty();
+ final Runnable action = () -> executionCounter.set(executionCounter.get() + 1);
+
+ timerActionComponent.runOnceAfter(action, Duration.seconds(1));
+
+ assertEquals(0, executionCounter.get());
+ timerActionComponent.onUpdate(0.999f);
+ assertEquals(0, executionCounter.get());
+ }
+
+ /**
+ * Tests that the action is not executed if cancelled before the delay elapses.
+ * Tests the method {@link TimerActionComponent#runOnceAfter(Runnable, Duration) runOnceAfter}.
+ */
+ @Test
+ public void testRunOnceCancelled() {
+ final TimerActionComponent timerActionComponent = new TimerActionComponent();
+ final IntegerProperty executionCounter = new SimpleIntegerProperty();
+ final Runnable action = () -> executionCounter.set(executionCounter.get() + 1);
+
+ TimerAction timerAction = timerActionComponent.runOnceAfter(action, Duration.seconds(1));
+ timerAction.expire();
+
+ assertEquals(0, executionCounter.get());
+ timerActionComponent.onUpdate(1.0f);
+ assertEquals(0, executionCounter.get());
+ }
+
+ /**
+ * Tests that multiple actions can be scheduled.
+ */
+ @Test
+ public void testRunOnceWithMultiple() {
+ final TimerActionComponent timerActionComponent = new TimerActionComponent();
+ final IntegerProperty executionCounter = new SimpleIntegerProperty();
+ final Runnable action = () -> executionCounter.set(executionCounter.get() + 1);
+
+ timerActionComponent.runOnceAfter(action, Duration.seconds(0.1f));
+ timerActionComponent.runOnceAfter(action, Duration.seconds(0.2f));
+ timerActionComponent.runOnceAfter(action, Duration.seconds(0.29f)); /* Floating point cannot represent 0.3 well */
+ timerActionComponent.runOnceAfter(action, Duration.seconds(0.4f));
+ timerActionComponent.runOnceAfter(action, Duration.seconds(0.5f));
+
+
+ for(int i = 0; i < 5; ++i) {
+ timerActionComponent.onUpdate(0.1f);
+ assertEquals(i + 1, executionCounter.get());
+ }
+ }
+
+ /**
+ * Tests that clearing the component will remove all actions.
+ */
+ @Test
+ public void testClear() {
+ final TimerActionComponent timerActionComponent = new TimerActionComponent();
+ final IntegerProperty executionCounter = new SimpleIntegerProperty();
+ final Runnable action = () -> executionCounter.set(executionCounter.get() + 1);
+
+ timerActionComponent.runAtInterval(action, Duration.seconds(1));
+ timerActionComponent.runOnceAfter(action, Duration.seconds(0.1f));
+
+ timerActionComponent.clear();
+ timerActionComponent.onUpdate(1.0f);
+ assertEquals(0, executionCounter.get());
+ }
+
+}
diff --git a/fxgl-samples/src/main/java/intermediate/components/PongVsComputerApp.java b/fxgl-samples/src/main/java/intermediate/components/PongVsComputerApp.java
new file mode 100644
index 0000000000..84611ef7d3
--- /dev/null
+++ b/fxgl-samples/src/main/java/intermediate/components/PongVsComputerApp.java
@@ -0,0 +1,150 @@
+/*
+ * FXGL - JavaFX Game Library. The MIT License (MIT).
+ * Copyright (c) AlmasB (almaslvl@gmail.com).
+ * See LICENSE for details.
+ */
+
+package intermediate.components;
+
+import com.almasb.fxgl.app.GameApplication;
+import com.almasb.fxgl.app.GameSettings;
+import com.almasb.fxgl.entity.Entity;
+import com.almasb.fxgl.entity.component.TimerActionComponent;
+import javafx.geometry.Point2D;
+import javafx.scene.input.KeyCode;
+import javafx.scene.paint.Color;
+import javafx.scene.shape.Rectangle;
+import javafx.scene.text.Text;
+import javafx.util.Duration;
+
+import java.util.Map;
+
+import static com.almasb.fxgl.dsl.FXGL.*;
+
+public class PongVsComputerApp extends GameApplication {
+
+ private static final int PADDLE_WIDTH = 30;
+ private static final int PADDLE_HEIGHT = 100;
+ private static final int BALL_SIZE = 20;
+ private static final int PADDLE_SPEED = 5;
+ private static final int BALL_SPEED = 5;
+
+ private Entity ball;
+ private Entity playerPaddle;
+ private Entity computerPaddle;
+
+ @Override
+ protected void initSettings(GameSettings settings) {
+ settings.setTitle("Impossible Pong");
+ }
+
+ @Override
+ protected void initInput() {
+ onKey(KeyCode.W, () -> playerPaddle.translateY(-PADDLE_SPEED));
+ onKey(KeyCode.S, () -> playerPaddle.translateY(PADDLE_SPEED));
+ }
+
+ @Override
+ protected void initGameVars(Map vars) {
+ vars.put("score1", 0);
+ vars.put("score2", 0);
+ }
+
+ @Override
+ protected void initGame() {
+ ball = spawnBall(getAppWidth() / 2 - BALL_SIZE / 2, getAppHeight() / 2 - BALL_SIZE / 2);
+ playerPaddle = spawnPlayerBat(0, getAppHeight() / 2 - PADDLE_HEIGHT / 2);
+ computerPaddle = spawnComputerBat(getAppWidth() - PADDLE_WIDTH, getAppHeight() / 2 - PADDLE_HEIGHT / 2);
+ }
+
+ @Override
+ protected void initUI() {
+ Text textScore1 = getUIFactoryService().newText("", Color.BLACK, 22);
+ Text textScore2 = getUIFactoryService().newText("", Color.BLACK, 22);
+
+ textScore1.textProperty().bind(getip("score1").asString());
+ textScore2.textProperty().bind(getip("score2").asString());
+
+ addUINode(textScore1, 10, 50);
+ addUINode(textScore2, getAppWidth() - 30, 50);
+ }
+
+ @Override
+ protected void onUpdate(double tpf) {
+ Point2D velocity = ball.getObject("velocity");
+ ball.translate(velocity);
+
+ if (ball.getX() == playerPaddle.getRightX()
+ && ball.getY() < playerPaddle.getBottomY()
+ && ball.getBottomY() > playerPaddle.getY()) {
+ ball.setProperty("velocity", new Point2D(-velocity.getX(), velocity.getY()));
+ }
+
+ if (ball.getRightX() == computerPaddle.getX()
+ && ball.getY() < computerPaddle.getBottomY()
+ && ball.getBottomY() > computerPaddle.getY()) {
+ ball.setProperty("velocity", new Point2D(-velocity.getX(), velocity.getY()));
+ }
+
+ if (ball.getX() <= 0) {
+ inc("score2", +1);
+ resetBall();
+ }
+
+ if (ball.getRightX() >= getAppWidth()) {
+ inc("score1", +1);
+ resetBall();
+ }
+
+ if (ball.getY() <= 0) {
+ ball.setY(0);
+ ball.setProperty("velocity", new Point2D(velocity.getX(), -velocity.getY()));
+ }
+
+ if (ball.getBottomY() >= getAppHeight()) {
+ ball.setY(getAppHeight() - BALL_SIZE);
+ ball.setProperty("velocity", new Point2D(velocity.getX(), -velocity.getY()));
+ }
+ }
+
+ private Entity spawnPlayerBat(double x, double y) {
+ return entityBuilder()
+ .at(x, y)
+ .viewWithBBox(new Rectangle(PADDLE_WIDTH, PADDLE_HEIGHT))
+ .buildAndAttach();
+ }
+
+ private Entity spawnComputerBat(double x, double y) {
+ TimerActionComponent timerComponent = new TimerActionComponent();
+
+ /* Move the Y position of the computer bat to the Y position of the ball */
+ timerComponent.runAtInterval(() -> {
+ Entity ball = timerComponent.getEntity().getObject("ballEntity");
+ timerComponent.getEntity().setY(ball.getY() - timerComponent.getEntity().getHeight() / 2f);
+ }, Duration.seconds(1 / 60f));
+
+ return entityBuilder()
+ .at(x, y)
+ .viewWithBBox(new Rectangle(PADDLE_WIDTH, PADDLE_HEIGHT))
+ .with(timerComponent)
+ .with("ballEntity", ball)
+ .buildAndAttach();
+ }
+
+ private Entity spawnBall(double x, double y) {
+ return entityBuilder()
+ .at(x, y)
+ .viewWithBBox(new Rectangle(BALL_SIZE, BALL_SIZE))
+ .with("velocity", new Point2D(BALL_SPEED, BALL_SPEED))
+ .buildAndAttach();
+ }
+
+ private void resetBall() {
+ ball.setPosition(getAppWidth() / 2 - BALL_SIZE / 2, getAppHeight() / 2 - BALL_SIZE / 2);
+ ball.setProperty("velocity", new Point2D(BALL_SPEED, BALL_SPEED));
+ }
+
+ public static void main(String[] args) {
+ launch(args);
+ }
+}