diff --git a/CMakeLists.txt b/CMakeLists.txt
index eb6b161f..bb155ab7 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -46,6 +46,7 @@ target_sources(scratchcpp
     include/scratchcpp/target.h
     include/scratchcpp/stage.h
     include/scratchcpp/sprite.h
+    include/scratchcpp/itimer.h
 )
 
 add_library(zip SHARED
diff --git a/include/scratchcpp/iengine.h b/include/scratchcpp/iengine.h
index c6d2312a..b622f665 100644
--- a/include/scratchcpp/iengine.h
+++ b/include/scratchcpp/iengine.h
@@ -19,6 +19,7 @@ class Sprite;
 class Variable;
 class List;
 class Script;
+class ITimer;
 
 /*!
  * \brief The IEngine interface provides an API for running Scratch projects.
@@ -106,6 +107,9 @@ class LIBSCRATCHCPP_EXPORT IEngine
         /*! Call this from a block implementation to ignore calls to skipFrame() until the current frame ends. */
         virtual void lockFrame() = 0;
 
+        /*! Returns the timer of the project. */
+        virtual ITimer *timer() const = 0;
+
         /*!
          * Registers the given block section.
          * \see <a href="blockSections.html">Block sections</a>
diff --git a/include/scratchcpp/itimer.h b/include/scratchcpp/itimer.h
new file mode 100644
index 00000000..00c2ae9a
--- /dev/null
+++ b/include/scratchcpp/itimer.h
@@ -0,0 +1,25 @@
+// SPDX-License-Identifier: Apache-2.0
+
+#pragma once
+
+namespace libscratchcpp
+{
+
+/*!
+ * \brief The ITimer interface represents a timer of a Scratch project.
+ *
+ * You can get a project timer using Engine#timer().
+ */
+class ITimer
+{
+    public:
+        virtual ~ITimer() { }
+
+        /*! Returns the time since last reset in seconds. */
+        virtual double value() const = 0;
+
+        /*! Resets the timer. */
+        virtual void reset() = 0;
+};
+
+} // namespace libscratchcpp
diff --git a/src/engine/CMakeLists.txt b/src/engine/CMakeLists.txt
index 5984bcf9..6ab5078d 100644
--- a/src/engine/CMakeLists.txt
+++ b/src/engine/CMakeLists.txt
@@ -11,6 +11,11 @@ target_sources(scratchcpp
     script_p.h
     internal/engine.cpp
     internal/engine.h
+    internal/clock.cpp
+    internal/clock.h
+    internal/iclock.h
+    internal/timer.cpp
+    internal/timer.h
     internal/blocksectioncontainer.cpp
     internal/blocksectioncontainer.h
     internal/global.h
diff --git a/src/engine/internal/clock.cpp b/src/engine/internal/clock.cpp
new file mode 100644
index 00000000..29fe4a5b
--- /dev/null
+++ b/src/engine/internal/clock.cpp
@@ -0,0 +1,21 @@
+// SPDX-License-Identifier: Apache-2.0
+
+#include "clock.h"
+
+using namespace libscratchcpp;
+
+std::shared_ptr<Clock> Clock::m_instance = std::make_shared<Clock>();
+
+Clock::Clock()
+{
+}
+
+std::shared_ptr<Clock> Clock::instance()
+{
+    return m_instance;
+}
+
+std::chrono::steady_clock::time_point Clock::currentTime() const
+{
+    return std::chrono::steady_clock::now();
+}
diff --git a/src/engine/internal/clock.h b/src/engine/internal/clock.h
new file mode 100644
index 00000000..053b1723
--- /dev/null
+++ b/src/engine/internal/clock.h
@@ -0,0 +1,25 @@
+// SPDX-License-Identifier: Apache-2.0
+
+#pragma once
+
+#include <memory>
+#include "iclock.h"
+
+namespace libscratchcpp
+{
+
+class Clock : public IClock
+{
+    public:
+        Clock();
+        Clock(const Clock &) = delete;
+
+        static std::shared_ptr<Clock> instance();
+
+        std::chrono::steady_clock::time_point currentTime() const override;
+
+    private:
+        static std::shared_ptr<Clock> m_instance;
+};
+
+} // namespace libscratchcpp
diff --git a/src/engine/internal/engine.cpp b/src/engine/internal/engine.cpp
index b38bf80c..135d928d 100644
--- a/src/engine/internal/engine.cpp
+++ b/src/engine/internal/engine.cpp
@@ -18,11 +18,14 @@
 
 #include "engine.h"
 #include "blocksectioncontainer.h"
+#include "timer.h"
 #include "../../blocks/standardblocks.h"
 
 using namespace libscratchcpp;
 
-Engine::Engine()
+Engine::Engine() :
+    m_defaultTimer(std::make_unique<Timer>()),
+    m_timer(m_defaultTimer.get())
 {
     srand(time(NULL));
 }
@@ -165,6 +168,8 @@ void Engine::frame()
 
 void Engine::start()
 {
+    m_timer->reset();
+
     for (auto target : m_targets) {
         auto gfBlocks = target->greenFlagBlocks();
         for (auto block : gfBlocks)
@@ -377,6 +382,16 @@ void Engine::lockFrame()
     m_lockFrame = true;
 }
 
+ITimer *Engine::timer() const
+{
+    return m_timer;
+}
+
+void Engine::setTimer(ITimer *timer)
+{
+    m_timer = timer;
+}
+
 void Engine::registerSection(std::shared_ptr<IBlockSection> section)
 {
     if (section) {
diff --git a/src/engine/internal/engine.h b/src/engine/internal/engine.h
index 06980bbc..04f519ba 100644
--- a/src/engine/internal/engine.h
+++ b/src/engine/internal/engine.h
@@ -4,6 +4,7 @@
 
 #include <scratchcpp/iengine.h>
 #include <scratchcpp/target.h>
+#include <scratchcpp/itimer.h>
 #include <unordered_map>
 #include <memory>
 #include <chrono>
@@ -43,6 +44,9 @@ class Engine : public IEngine
         void skipFrame() override;
         void lockFrame() override;
 
+        ITimer *timer() const override;
+        void setTimer(ITimer *timer);
+
         void registerSection(std::shared_ptr<IBlockSection> section) override;
         std::vector<std::shared_ptr<IBlockSection>> registeredSections() const;
         unsigned int functionIndex(BlockFunc f) override;
@@ -101,6 +105,9 @@ class Engine : public IEngine
         std::unordered_map<Variable *, Target *> m_variableOwners;
         std::unordered_map<List *, Target *> m_listOwners;
 
+        std::unique_ptr<ITimer> m_defaultTimer;
+        ITimer *m_timer = nullptr;
+
         bool m_breakFrame = false;
         bool m_skipFrame = false;
         bool m_lockFrame = false;
diff --git a/src/engine/internal/iclock.h b/src/engine/internal/iclock.h
new file mode 100644
index 00000000..96b0bb55
--- /dev/null
+++ b/src/engine/internal/iclock.h
@@ -0,0 +1,18 @@
+// SPDX-License-Identifier: Apache-2.0
+
+#pragma once
+
+#include <chrono>
+
+namespace libscratchcpp
+{
+
+class IClock
+{
+    public:
+        virtual ~IClock() { }
+
+        virtual std::chrono::steady_clock::time_point currentTime() const = 0;
+};
+
+} // namespace libscratchcpp
diff --git a/src/engine/internal/timer.cpp b/src/engine/internal/timer.cpp
new file mode 100644
index 00000000..53335558
--- /dev/null
+++ b/src/engine/internal/timer.cpp
@@ -0,0 +1,37 @@
+// SPDX-License-Identifier: Apache-2.0
+
+#include <cassert>
+
+#include "timer.h"
+#include "clock.h"
+
+using namespace libscratchcpp;
+
+Timer::Timer()
+{
+    m_clock = Clock::instance().get();
+    resetTimer();
+}
+
+Timer::Timer(IClock *clock) :
+    m_clock(clock)
+{
+    assert(clock);
+    resetTimer();
+}
+
+double Timer::value() const
+{
+    return std::chrono::duration_cast<std::chrono::milliseconds>(m_clock->currentTime() - m_startTime).count() / 1000.0;
+}
+
+void Timer::reset()
+{
+    resetTimer();
+}
+
+// Required to avoid calling the virtual method from the constructors
+void Timer::resetTimer()
+{
+    m_startTime = m_clock->currentTime();
+}
diff --git a/src/engine/internal/timer.h b/src/engine/internal/timer.h
new file mode 100644
index 00000000..7b3aed84
--- /dev/null
+++ b/src/engine/internal/timer.h
@@ -0,0 +1,30 @@
+// SPDX-License-Identifier: Apache-2.0
+
+#pragma once
+
+#include <scratchcpp/itimer.h>
+#include <chrono>
+
+namespace libscratchcpp
+{
+
+class IClock;
+
+class Timer : public ITimer
+{
+    public:
+        Timer();
+        Timer(IClock *clock);
+        Timer(const Timer &) = delete;
+
+        double value() const override;
+        void reset() override;
+
+    private:
+        void resetTimer();
+
+        std::chrono::steady_clock::time_point m_startTime;
+        IClock *m_clock = nullptr;
+};
+
+} // namespace libscratchcpp
diff --git a/test/CMakeLists.txt b/test/CMakeLists.txt
index 385210a4..efa66c18 100644
--- a/test/CMakeLists.txt
+++ b/test/CMakeLists.txt
@@ -28,3 +28,5 @@ add_subdirectory(assets)
 add_subdirectory(script)
 add_subdirectory(extensions)
 add_subdirectory(engine)
+add_subdirectory(clock)
+add_subdirectory(timer)
diff --git a/test/clock/CMakeLists.txt b/test/clock/CMakeLists.txt
new file mode 100644
index 00000000..2e4f83a0
--- /dev/null
+++ b/test/clock/CMakeLists.txt
@@ -0,0 +1,12 @@
+add_executable(
+  clock_test
+  clock_test.cpp
+)
+
+target_link_libraries(
+  clock_test
+  GTest::gtest_main
+  scratchcpp
+)
+
+gtest_discover_tests(clock_test)
diff --git a/test/clock/clock_test.cpp b/test/clock/clock_test.cpp
new file mode 100644
index 00000000..a1bbc659
--- /dev/null
+++ b/test/clock/clock_test.cpp
@@ -0,0 +1,20 @@
+#include <engine/internal/clock.h>
+#include <cmath>
+
+#include "../common.h"
+
+using namespace libscratchcpp;
+
+TEST(ClockTest, CurrentTime)
+{
+    auto clock = Clock::instance();
+    ASSERT_TRUE(clock);
+
+    auto currentTime = std::chrono::time_point_cast<std::chrono::milliseconds>(std::chrono::steady_clock::now());
+    double currentTimeMs = std::round(currentTime.time_since_epoch().count() / 10) * 10;
+
+    auto reportedTime = std::chrono::time_point_cast<std::chrono::milliseconds>(clock->currentTime());
+    double reportedTimeMs = std::round(reportedTime.time_since_epoch().count() / 10) * 10;
+
+    ASSERT_EQ(reportedTimeMs, currentTimeMs);
+}
diff --git a/test/engine/CMakeLists.txt b/test/engine/CMakeLists.txt
index ea2440ac..b8d97c5c 100644
--- a/test/engine/CMakeLists.txt
+++ b/test/engine/CMakeLists.txt
@@ -8,7 +8,9 @@ add_executable(
 target_link_libraries(
   engine_test
   GTest::gtest_main
+  GTest::gmock_main
   scratchcpp
+  scratchcpp_mocks
 )
 
 gtest_discover_tests(engine_test)
diff --git a/test/engine/engine_test.cpp b/test/engine/engine_test.cpp
index cd45c1d2..12620f4a 100644
--- a/test/engine/engine_test.cpp
+++ b/test/engine/engine_test.cpp
@@ -3,6 +3,7 @@
 #include <scratchcpp/project.h>
 #include <scratchcpp/variable.h>
 #include <scratchcpp/list.h>
+#include <timermock.h>
 
 #include "../common.h"
 #include "testsection.h"
@@ -42,6 +43,26 @@ TEST(EngineTest, BreakFrame)
     ASSERT_TRUE(engine.breakingCurrentFrame());
 }
 
+TEST(EngineTest, Timer)
+{
+    Engine engine;
+    ASSERT_TRUE(engine.timer());
+    engine.timer()->reset(); // shouldn't crash
+
+    TimerMock timer;
+    engine.setTimer(&timer);
+    ASSERT_EQ(engine.timer(), &timer);
+
+    EXPECT_CALL(timer, reset()).Times(1);
+    engine.start();
+
+    EXPECT_CALL(timer, reset()).Times(0);
+    engine.stop();
+
+    EXPECT_CALL(timer, reset()).Times(1);
+    engine.run();
+}
+
 TEST(EngineTest, Sections)
 {
     Engine engine;
diff --git a/test/mocks/clockmock.h b/test/mocks/clockmock.h
new file mode 100644
index 00000000..5e16bfa9
--- /dev/null
+++ b/test/mocks/clockmock.h
@@ -0,0 +1,12 @@
+#pragma once
+
+#include <engine/internal/iclock.h>
+#include <gmock/gmock.h>
+
+using namespace libscratchcpp;
+
+class ClockMock : public IClock
+{
+    public:
+        MOCK_METHOD(std::chrono::steady_clock::time_point, currentTime, (), (const override));
+};
diff --git a/test/mocks/enginemock.h b/test/mocks/enginemock.h
index 99c20c4d..0beda8ec 100644
--- a/test/mocks/enginemock.h
+++ b/test/mocks/enginemock.h
@@ -32,6 +32,8 @@ class EngineMock : public IEngine
         MOCK_METHOD(void, skipFrame, (), (override));
         MOCK_METHOD(void, lockFrame, (), (override));
 
+        MOCK_METHOD(ITimer *, timer, (), (const, override));
+
         MOCK_METHOD(void, registerSection, (std::shared_ptr<IBlockSection>), (override));
         MOCK_METHOD(unsigned int, functionIndex, (BlockFunc), (override));
 
diff --git a/test/mocks/timermock.h b/test/mocks/timermock.h
new file mode 100644
index 00000000..96cf5f8f
--- /dev/null
+++ b/test/mocks/timermock.h
@@ -0,0 +1,14 @@
+#pragma once
+
+#include <scratchcpp/itimer.h>
+#include <gmock/gmock.h>
+
+using namespace libscratchcpp;
+
+class TimerMock : public ITimer
+{
+    public:
+        MOCK_METHOD(double, value, (), (const, override));
+
+        MOCK_METHOD(void, reset, (), (override));
+};
diff --git a/test/timer/CMakeLists.txt b/test/timer/CMakeLists.txt
new file mode 100644
index 00000000..5041f24e
--- /dev/null
+++ b/test/timer/CMakeLists.txt
@@ -0,0 +1,14 @@
+add_executable(
+  timer_test
+  timer_test.cpp
+)
+
+target_link_libraries(
+  timer_test
+  GTest::gtest_main
+  GTest::gmock_main
+  scratchcpp
+  scratchcpp_mocks
+)
+
+gtest_discover_tests(timer_test)
diff --git a/test/timer/timer_test.cpp b/test/timer/timer_test.cpp
new file mode 100644
index 00000000..1daa0d33
--- /dev/null
+++ b/test/timer/timer_test.cpp
@@ -0,0 +1,33 @@
+#include <engine/internal/timer.h>
+#include <clockmock.h>
+
+#include "../common.h"
+
+using namespace libscratchcpp;
+
+using ::testing::Return;
+
+TEST(TimerTest, ValueAndReset)
+{
+    ClockMock clock;
+
+    std::chrono::steady_clock::time_point startTime(std::chrono::milliseconds(50));
+    EXPECT_CALL(clock, currentTime()).WillOnce(Return(startTime));
+    Timer timer(&clock);
+
+    std::chrono::steady_clock::time_point time1(std::chrono::milliseconds(73));
+    EXPECT_CALL(clock, currentTime()).WillOnce(Return(time1));
+    ASSERT_EQ(timer.value(), 0.023);
+
+    std::chrono::steady_clock::time_point time2(std::chrono::milliseconds(15432));
+    EXPECT_CALL(clock, currentTime()).WillOnce(Return(time2));
+    ASSERT_EQ(timer.value(), 15.382);
+
+    std::chrono::steady_clock::time_point time3(std::chrono::milliseconds(16025));
+    EXPECT_CALL(clock, currentTime()).WillOnce(Return(time3));
+    timer.reset();
+
+    std::chrono::steady_clock::time_point time4(std::chrono::milliseconds(24632));
+    EXPECT_CALL(clock, currentTime()).WillOnce(Return(time4));
+    ASSERT_EQ(timer.value(), 8.607);
+}
