diff --git a/CMakeLists.txt b/CMakeLists.txt
index bb155ab7..21ae1c1a 100644
--- a/CMakeLists.txt
+++ b/CMakeLists.txt
@@ -47,6 +47,7 @@ target_sources(scratchcpp
     include/scratchcpp/stage.h
     include/scratchcpp/sprite.h
     include/scratchcpp/itimer.h
+    include/scratchcpp/keyevent.h
 )
 
 add_library(zip SHARED
diff --git a/include/scratchcpp/keyevent.h b/include/scratchcpp/keyevent.h
new file mode 100644
index 00000000..54ca82c4
--- /dev/null
+++ b/include/scratchcpp/keyevent.h
@@ -0,0 +1,37 @@
+// SPDX-License-Identifier: Apache-2.0
+
+#pragma once
+
+#include "global.h"
+#include "spimpl.h"
+
+namespace libscratchcpp
+{
+
+class KeyEventPrivate;
+
+class LIBSCRATCHCPP_EXPORT KeyEvent
+{
+    public:
+        enum class Type
+        {
+            Any,
+            Space,
+            Left,
+            Up,
+            Right,
+            Down,
+            Enter
+        };
+
+        KeyEvent(Type type = Type::Any);
+        KeyEvent(const std::string &name);
+
+        Type type() const;
+        const std::string &name() const;
+
+    private:
+        spimpl::impl_ptr<KeyEventPrivate> impl;
+};
+
+} // namespace libscratchcpp
diff --git a/src/scratch/CMakeLists.txt b/src/scratch/CMakeLists.txt
index aa2a9d0e..6cde28d3 100644
--- a/src/scratch/CMakeLists.txt
+++ b/src/scratch/CMakeLists.txt
@@ -45,4 +45,7 @@ target_sources(scratchcpp
     entity.cpp
     entity_p.cpp
     entity_p.h
+    keyevent.cpp
+    keyevent_p.cpp
+    keyevent_p.h
 )
diff --git a/src/scratch/keyevent.cpp b/src/scratch/keyevent.cpp
new file mode 100644
index 00000000..a63d124e
--- /dev/null
+++ b/src/scratch/keyevent.cpp
@@ -0,0 +1,34 @@
+// SPDX-License-Identifier: Apache-2.0
+
+#include <scratchcpp/keyevent.h>
+
+#include "keyevent_p.h"
+
+namespace libscratchcpp
+{
+
+/*! Constructs KeyEvent. */
+KeyEvent::KeyEvent(Type type) :
+    impl(spimpl::make_impl<KeyEventPrivate>(type))
+{
+}
+
+/*! Constructs KeyEvent from a key name. */
+KeyEvent::KeyEvent(const std::string &name) :
+    impl(spimpl::make_impl<KeyEventPrivate>(name))
+{
+}
+
+/*! Returns the type of the key. */
+KeyEvent::Type KeyEvent::type() const
+{
+    return impl->type;
+}
+
+/*! Returns the name of the key. */
+const std::string &KeyEvent::name() const
+{
+    return impl->name;
+}
+
+} // namespace libscratchcpp
diff --git a/src/scratch/keyevent_p.cpp b/src/scratch/keyevent_p.cpp
new file mode 100644
index 00000000..ed22c467
--- /dev/null
+++ b/src/scratch/keyevent_p.cpp
@@ -0,0 +1,105 @@
+// SPDX-License-Identifier: Apache-2.0
+
+#include <unordered_map>
+#include <algorithm>
+
+#include "keyevent_p.h"
+
+namespace libscratchcpp
+{
+
+static std::unordered_map<KeyEvent::Type, std::string> KEY_NAME = {
+    { KeyEvent::Type::Space, "space" },       { KeyEvent::Type::Left, "left arrow" }, { KeyEvent::Type::Up, "up arrow" },
+    { KeyEvent::Type::Right, "right arrow" }, { KeyEvent::Type::Down, "down arrow" }, { KeyEvent::Type::Enter, "enter" }
+};
+
+KeyEventPrivate::KeyEventPrivate(KeyEvent::Type type) :
+    type(type)
+{
+    auto it = KEY_NAME.find(type);
+
+    if (it == KEY_NAME.cend())
+        this->type = KeyEvent::Type::Any;
+    else
+        name = it->second;
+}
+
+KeyEventPrivate::KeyEventPrivate(const std::string &name) :
+    name(name)
+{
+    // See https://github.com/scratchfoundation/scratch-vm/blob/2a00145b8a968b04f278808858f79fa6d6e79946/src/io/keyboard.js#L93
+
+    // Convert from ASCII if a number was dropped in
+    try {
+        double numberDouble = std::stod(name);
+        long number = static_cast<long>(numberDouble);
+
+        if (numberDouble == number) {
+
+            if (number >= 48 && number <= 90) {
+                this->name = static_cast<char>(number);
+                convertNameToLowercase();
+
+                return;
+            }
+
+            switch (number) {
+                case 32:
+                    type = KeyEvent::Type::Space;
+                    break;
+
+                case 37:
+                    type = KeyEvent::Type::Left;
+                    break;
+
+                case 38:
+                    type = KeyEvent::Type::Up;
+                    break;
+
+                case 39:
+                    type = KeyEvent::Type::Right;
+                    break;
+
+                case 40:
+                    type = KeyEvent::Type::Down;
+                    break;
+
+                default:
+                    break;
+            }
+
+            if (type != KeyEvent::Type::Any) {
+                this->name = KEY_NAME[type];
+                return;
+            }
+        }
+    } catch (...) {
+    }
+
+    // Check for a special key
+    for (const auto &[key, value] : KEY_NAME) {
+        if (value == name) {
+            type = key;
+            return;
+        }
+    }
+
+    // Use only the first character
+    if (this->name.size() > 1)
+        this->name = this->name[0];
+
+    // Check for the space character
+    if (this->name == " ") {
+        type = KeyEvent::Type::Space;
+        this->name = KEY_NAME[type];
+    }
+
+    convertNameToLowercase();
+}
+
+void KeyEventPrivate::convertNameToLowercase()
+{
+    std::transform(this->name.begin(), this->name.end(), this->name.begin(), ::tolower);
+}
+
+} // namespace libscratchcpp
diff --git a/src/scratch/keyevent_p.h b/src/scratch/keyevent_p.h
new file mode 100644
index 00000000..4da4984a
--- /dev/null
+++ b/src/scratch/keyevent_p.h
@@ -0,0 +1,22 @@
+// SPDX-License-Identifier: Apache-2.0
+
+#pragma once
+
+#include <scratchcpp/keyevent.h>
+#include <string>
+
+namespace libscratchcpp
+{
+
+struct KeyEventPrivate
+{
+        KeyEventPrivate(KeyEvent::Type type);
+        KeyEventPrivate(const std::string &name);
+
+        void convertNameToLowercase();
+
+        KeyEvent::Type type = KeyEvent::Type::Any;
+        std::string name;
+};
+
+} // namespace libscratchcpp
diff --git a/test/scratch_classes/CMakeLists.txt b/test/scratch_classes/CMakeLists.txt
index dfdd4c21..c98db0e3 100644
--- a/test/scratch_classes/CMakeLists.txt
+++ b/test/scratch_classes/CMakeLists.txt
@@ -187,3 +187,17 @@ target_link_libraries(
 )
 
 gtest_discover_tests(variable_test)
+
+# keyevent_test
+add_executable(
+  keyevent_test
+  keyevent_test.cpp
+)
+
+target_link_libraries(
+  keyevent_test
+  GTest::gtest_main
+  scratchcpp
+)
+
+gtest_discover_tests(keyevent_test)
diff --git a/test/scratch_classes/keyevent_test.cpp b/test/scratch_classes/keyevent_test.cpp
new file mode 100644
index 00000000..280f6482
--- /dev/null
+++ b/test/scratch_classes/keyevent_test.cpp
@@ -0,0 +1,215 @@
+#include <scratchcpp/keyevent.h>
+
+#include "../common.h"
+
+using namespace libscratchcpp;
+
+TEST(KeyEventTest, Constructors)
+{
+    // Default constructor
+    {
+        KeyEvent event;
+        ASSERT_EQ(event.type(), KeyEvent::Type::Any);
+        ASSERT_TRUE(event.name().empty());
+    }
+
+    // Type
+    {
+        KeyEvent event(KeyEvent::Type::Any);
+        ASSERT_EQ(event.type(), KeyEvent::Type::Any);
+        ASSERT_TRUE(event.name().empty());
+    }
+
+    {
+        KeyEvent event(KeyEvent::Type::Space);
+        ASSERT_EQ(event.type(), KeyEvent::Type::Space);
+        ASSERT_EQ(event.name(), "space");
+    }
+
+    {
+        KeyEvent event(KeyEvent::Type::Left);
+        ASSERT_EQ(event.type(), KeyEvent::Type::Left);
+        ASSERT_EQ(event.name(), "left arrow");
+    }
+
+    {
+        KeyEvent event(KeyEvent::Type::Up);
+        ASSERT_EQ(event.type(), KeyEvent::Type::Up);
+        ASSERT_EQ(event.name(), "up arrow");
+    }
+
+    {
+        KeyEvent event(KeyEvent::Type::Right);
+        ASSERT_EQ(event.type(), KeyEvent::Type::Right);
+        ASSERT_EQ(event.name(), "right arrow");
+    }
+
+    {
+        KeyEvent event(KeyEvent::Type::Down);
+        ASSERT_EQ(event.type(), KeyEvent::Type::Down);
+        ASSERT_EQ(event.name(), "down arrow");
+    }
+
+    {
+        KeyEvent event(KeyEvent::Type::Enter);
+        ASSERT_EQ(event.type(), KeyEvent::Type::Enter);
+        ASSERT_EQ(event.name(), "enter");
+    }
+
+    {
+        KeyEvent event(static_cast<KeyEvent::Type>(-1));
+        ASSERT_EQ(event.type(), KeyEvent::Type::Any);
+        ASSERT_TRUE(event.name().empty());
+    }
+
+    // Name
+    {
+        KeyEvent event("");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Any);
+        ASSERT_TRUE(event.name().empty());
+    }
+
+    {
+        KeyEvent event("a");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Any);
+        ASSERT_EQ(event.name(), "a");
+    }
+
+    {
+        KeyEvent event("D");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Any);
+        ASSERT_EQ(event.name(), "d");
+    }
+
+    {
+        KeyEvent event("0");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Any);
+        ASSERT_EQ(event.name(), "0");
+    }
+
+    {
+        KeyEvent event("space");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Space);
+        ASSERT_EQ(event.name(), "space");
+    }
+
+    {
+        KeyEvent event("left arrow");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Left);
+        ASSERT_EQ(event.name(), "left arrow");
+    }
+
+    {
+        KeyEvent event("up arrow");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Up);
+        ASSERT_EQ(event.name(), "up arrow");
+    }
+
+    {
+        KeyEvent event("right arrow");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Right);
+        ASSERT_EQ(event.name(), "right arrow");
+    }
+
+    {
+        KeyEvent event("down arrow");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Down);
+        ASSERT_EQ(event.name(), "down arrow");
+    }
+
+    {
+        KeyEvent event("enter");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Enter);
+        ASSERT_EQ(event.name(), "enter");
+    }
+
+    {
+        KeyEvent event("Enter");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Any);
+        ASSERT_EQ(event.name(), "e");
+    }
+
+    {
+        KeyEvent event("test");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Any);
+        ASSERT_EQ(event.name(), "t");
+    }
+
+    {
+        KeyEvent event(" ");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Space);
+        ASSERT_EQ(event.name(), "space");
+    }
+
+    {
+        KeyEvent event(" a");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Space);
+        ASSERT_EQ(event.name(), "space");
+    }
+
+    {
+        KeyEvent event("33");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Any);
+        ASSERT_EQ(event.name(), "3");
+    }
+
+    {
+        KeyEvent event("47");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Any);
+        ASSERT_EQ(event.name(), "4");
+    }
+
+    {
+        KeyEvent event("48");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Any);
+        ASSERT_EQ(event.name(), "0");
+    }
+
+    {
+        KeyEvent event("64");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Any);
+        ASSERT_EQ(event.name(), "@");
+    }
+
+    {
+        KeyEvent event("77");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Any);
+        ASSERT_EQ(event.name(), "m");
+    }
+
+    {
+        KeyEvent event("90");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Any);
+        ASSERT_EQ(event.name(), "z");
+    }
+
+    {
+        KeyEvent event("32");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Space);
+        ASSERT_EQ(event.name(), "space");
+    }
+
+    {
+        KeyEvent event("37");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Left);
+        ASSERT_EQ(event.name(), "left arrow");
+    }
+
+    {
+        KeyEvent event("38");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Up);
+        ASSERT_EQ(event.name(), "up arrow");
+    }
+
+    {
+        KeyEvent event("39");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Right);
+        ASSERT_EQ(event.name(), "right arrow");
+    }
+
+    {
+        KeyEvent event("40");
+        ASSERT_EQ(event.type(), KeyEvent::Type::Down);
+        ASSERT_EQ(event.name(), "down arrow");
+    }
+}
