diff --git a/scripts/data/integration_tests/test_lua_api/player.lua b/scripts/data/integration_tests/test_lua_api/player.lua
new file mode 100644
index 0000000000..c46ab5c46e
--- /dev/null
+++ b/scripts/data/integration_tests/test_lua_api/player.lua
@@ -0,0 +1,63 @@
+local testing = require('testing_util')
+local self = require('openmw.self')
+local util = require('openmw.util')
+local core = require('openmw.core')
+local input = require('openmw.input')
+local types = require('openmw.types')
+
+input.setControlSwitch(input.CONTROL_SWITCH.Fighting, false)
+input.setControlSwitch(input.CONTROL_SWITCH.Jumping, false)
+input.setControlSwitch(input.CONTROL_SWITCH.Looking, false)
+input.setControlSwitch(input.CONTROL_SWITCH.Magic, false)
+input.setControlSwitch(input.CONTROL_SWITCH.VanityMode, false)
+input.setControlSwitch(input.CONTROL_SWITCH.ViewMode, false)
+
+testing.registerLocalTest('playerMovement',
+    function()
+        local startTime = core.getSimulationTime()
+        local pos = self.position
+
+        while core.getSimulationTime() < startTime + 0.5 do
+            self.controls.jump = false
+            self.controls.run = true
+            self.controls.movement = 0
+            self.controls.sideMovement = 0
+            local progress = (core.getSimulationTime() - startTime) / 0.5
+            self.controls.yawChange = util.normalizeAngle(math.rad(90) * progress - self.rotation.z)
+            coroutine.yield()
+        end
+        testing.expectEqualWithDelta(self.rotation.z, math.rad(90), 0.05, 'Incorrect rotation')
+
+        while core.getSimulationTime() < startTime + 1.5 do
+            self.controls.jump = false
+            self.controls.run = true
+            self.controls.movement = 1
+            self.controls.sideMovement = 0
+            self.controls.yawChange = 0
+            coroutine.yield()
+        end
+        direction = (self.position - pos) / types.Actor.runSpeed(self)
+        testing.expectEqualWithDelta(direction.x, 1, 0.1, 'Run forward, X coord')
+        testing.expectEqualWithDelta(direction.y, 0, 0.1, 'Run forward, Y coord')
+
+        pos = self.position
+        while core.getSimulationTime() < startTime + 2.5 do
+            self.controls.jump = false
+            self.controls.run = false
+            self.controls.movement = -1
+            self.controls.sideMovement = -1
+            self.controls.yawChange = 0
+            coroutine.yield()
+        end
+        direction = (self.position - pos) / types.Actor.walkSpeed(self)
+        testing.expectEqualWithDelta(direction.x, -0.707, 0.1, 'Walk diagonally, X coord')
+        testing.expectEqualWithDelta(direction.y, 0.707, 0.1, 'Walk diagonally, Y coord')
+    end)
+
+return {
+    engineHandlers = {
+        onUpdate = testing.updateLocal,
+    },
+    eventHandlers = testing.eventHandlers
+}
+
diff --git a/scripts/data/integration_tests/test_lua_api/test.lua b/scripts/data/integration_tests/test_lua_api/test.lua
new file mode 100644
index 0000000000..573844199b
--- /dev/null
+++ b/scripts/data/integration_tests/test_lua_api/test.lua
@@ -0,0 +1,67 @@
+local testing = require('testing_util')
+local core = require('openmw.core')
+local async = require('openmw.async')
+local util = require('openmw.util')
+
+local function testTimers()
+    testing.expectAlmostEqual(core.getGameTimeScale(), 30, 'incorrect getGameTimeScale() result')
+    testing.expectAlmostEqual(core.getSimulationTimeScale(), 1, 'incorrect getSimulationTimeScale result')
+
+    local startGameTime = core.getGameTime()
+    local startSimulationTime = core.getSimulationTime()
+
+    local ts1, ts2, th1, th2
+    local cb = async:registerTimerCallback("tfunc", function(arg)
+        if arg == 'g' then
+            th1 = core.getGameTime() - startGameTime
+        else
+            ts1 = core.getSimulationTime() - startSimulationTime
+        end
+    end)
+    async:newGameTimer(36, cb, 'g')
+    async:newSimulationTimer(0.5, cb, 's')
+    async:newUnsavableGameTimer(72, function()
+        th2 = core.getGameTime() - startGameTime
+    end)
+    async:newUnsavableSimulationTimer(1, function()
+        ts2 = core.getSimulationTime() - startSimulationTime
+    end)
+
+    while not (ts1 and ts2 and th1 and th2) do coroutine.yield() end
+
+    testing.expectAlmostEqual(th1, 36, 'async:newGameTimer failed')
+    testing.expectAlmostEqual(ts1, 0.5, 'async:newSimulationTimer failed')
+    testing.expectAlmostEqual(th2, 72, 'async:newUnsavableGameTimer failed')
+    testing.expectAlmostEqual(ts2, 1, 'async:newUnsavableSimulationTimer failed')
+end
+
+local function testTeleport()
+    player:teleport('', util.vector3(100, 50, 0), util.vector3(0, 0, math.rad(-90)))
+    coroutine.yield()
+    testing.expect(player.cell.isExterior, 'teleport to exterior failed')
+    testing.expectEqualWithDelta(player.position.x, 100, 1, 'incorrect position after teleporting')
+    testing.expectEqualWithDelta(player.position.y, 50, 1, 'incorrect position after teleporting')
+    testing.expectEqualWithDelta(player.rotation.z, math.rad(-90), 0.05, 'incorrect rotation after teleporting')
+
+    player:teleport('', util.vector3(50, -100, 0))
+    coroutine.yield()
+    testing.expect(player.cell.isExterior, 'teleport to exterior failed')
+    testing.expectEqualWithDelta(player.position.x, 50, 1, 'incorrect position after teleporting')
+    testing.expectEqualWithDelta(player.position.y, -100, 1, 'incorrect position after teleporting')
+    testing.expectEqualWithDelta(player.rotation.z, math.rad(-90), 0.05, 'teleporting changes rotation')
+end
+
+tests = {
+    {'timers', testTimers},
+    {'playerMovement', function() testing.runLocalTest(player, 'playerMovement') end},
+    {'teleport', testTeleport},
+}
+
+return {
+    engineHandlers = {
+        onUpdate = testing.testRunner(tests),
+        onPlayerAdded = function(p) player = p end,
+    },
+    eventHandlers = testing.eventHandlers,
+}
+
diff --git a/scripts/data/integration_tests/test_lua_api/test.omwscripts b/scripts/data/integration_tests/test_lua_api/test.omwscripts
new file mode 100644
index 0000000000..97f523afbd
--- /dev/null
+++ b/scripts/data/integration_tests/test_lua_api/test.omwscripts
@@ -0,0 +1,3 @@
+GLOBAL: test.lua
+PLAYER: player.lua
+
diff --git a/scripts/data/integration_tests/test_lua_api/testing_util.lua b/scripts/data/integration_tests/test_lua_api/testing_util.lua
new file mode 100644
index 0000000000..719502e741
--- /dev/null
+++ b/scripts/data/integration_tests/test_lua_api/testing_util.lua
@@ -0,0 +1,93 @@
+local core = require('openmw.core')
+
+local M = {}
+local currentLocalTest = nil
+local currentLocalTestError = nil
+
+function M.testRunner(tests)
+    local fn = function()
+        for i, test in ipairs(tests) do
+            local name, fn = unpack(test)
+            print('TEST_START', i, name)
+            local status, err = pcall(fn)
+            if status then
+                print('TEST_OK', i, name)
+            else
+                print('TEST_FAILED', i, name, err)
+            end
+        end
+        core.quit()
+    end
+    local co = coroutine.create(fn)
+    return function()
+        if coroutine.status(co) ~= 'dead' then
+            coroutine.resume(co)
+        end
+    end
+end
+
+function M.runLocalTest(obj, name)
+    currentLocalTest = name
+    currentLocalTestError = nil
+    obj:sendEvent('runLocalTest', name)
+    while currentLocalTest do coroutine.yield() end
+    if currentLocalTestError then error(currentLocalTestError, 2) end
+end
+
+function M.expect(cond, delta, msg)
+    if not cond then
+        error(msg or '"true" expected', 2)
+    end
+end
+
+function M.expectEqualWithDelta(v1, v2, delta, msg)
+    if math.abs(v1 - v2) > delta then
+        error(string.format('%s: %f ~= %f', msg or '', v1, v2), 2)
+    end
+end
+
+function M.expectAlmostEqual(v1, v2, msg)
+    if math.abs(v1 - v2) / (math.abs(v1) + math.abs(v2)) > 0.05 then
+        error(string.format('%s: %f ~= %f', msg or '', v1, v2), 2)
+    end
+end
+
+local localTests = {}
+local localTestRunner = nil
+
+function M.registerLocalTest(name, fn)
+    localTests[name] = fn
+end
+
+function M.updateLocal()
+    if localTestRunner and coroutine.status(localTestRunner) ~= 'dead' then
+        coroutine.resume(localTestRunner)
+    else
+        localTestRunner = nil
+    end
+end
+
+M.eventHandlers = {
+    runLocalTest = function(name)  -- used only in local scripts
+        fn = localTests[name]
+        if not fn then
+            core.sendGlobalEvent('localTestFinished', {name=name, errMsg='Test not found'})
+            return
+        end
+        localTestRunner = coroutine.create(function()
+            local status, err = pcall(fn)
+            if status then err = nil end
+            core.sendGlobalEvent('localTestFinished', {name=name, errMsg=err})
+        end)
+    end,
+    localTestFinished = function(data)  -- used only in global scripts
+        if data.name ~= currentLocalTest then
+            error(string.format('localTestFinished with incorrect name %s, expected %s', data.name, currentLocalTest))
+        end
+        currentLocalTest = nil
+        currentLocalTestError = data.errMsg
+    end,
+}
+
+return M
+
diff --git a/scripts/integration_tests.py b/scripts/integration_tests.py
new file mode 100755
index 0000000000..b125e688fb
--- /dev/null
+++ b/scripts/integration_tests.py
@@ -0,0 +1,87 @@
+#!/usr/bin/env python3
+
+import argparse, datetime, os, subprocess, sys, shutil
+from pathlib import Path
+
+parser = argparse.ArgumentParser(description="OpenMW integration tests.")
+parser.add_argument(
+    "example_suite",
+    type=str,
+    help="path to openmw example suite (use 'git clone https://gitlab.com/OpenMW/example-suite/' to get it)",
+)
+parser.add_argument("--omw", type=str, default="openmw", help="path to openmw binary")
+parser.add_argument(
+    "--workdir", type=str, default="integration_tests_output", help="directory for temporary files and logs"
+)
+args = parser.parse_args()
+
+example_suite_dir = Path(args.example_suite).resolve()
+example_suite_content = example_suite_dir / "game_template" / "data" / "template.omwgame"
+if not example_suite_content.is_file():
+    sys.exit(
+        f"{example_suite_content} not found, use 'git clone https://gitlab.com/OpenMW/example-suite/' to get it"
+    )
+
+openmw_binary = Path(args.omw).resolve()
+if not openmw_binary.is_file():
+    sys.exit(f"{openmw_binary} not found")
+
+work_dir = Path(args.workdir).resolve()
+work_dir.mkdir(parents=True, exist_ok=True)
+config_dir = work_dir / "config"
+userdata_dir = work_dir / "userdata"
+tests_dir = Path(__file__).resolve().parent / "data" / "integration_tests"
+time_str = datetime.datetime.now().strftime("%Y-%m-%d-%H.%M.%S")
+
+
+def runTest(name):
+    print(f"Start {name}")
+    shutil.rmtree(config_dir, ignore_errors=True)
+    config_dir.mkdir()
+    shutil.copyfile(example_suite_dir / "settings.cfg", config_dir / "settings.cfg")
+    test_dir = tests_dir / name
+    with open(config_dir / "openmw.cfg", "w", encoding="utf-8") as omw_cfg:
+        omw_cfg.writelines(
+            (
+                f'data="{example_suite_dir}{os.sep}game_template{os.sep}data"\n',
+                f'data-local="{test_dir}"\n',
+                f'user-data="{userdata_dir}"\n',
+                "content=template.omwgame\n",
+            )
+        )
+        if (test_dir / "test.omwscripts").exists():
+            omw_cfg.write("content=test.omwscripts\n")
+    with subprocess.Popen(
+        [f"{openmw_binary}", "--replace=config", f"--config={config_dir}", "--skip-menu", "--no-grab"],
+        stdout=subprocess.PIPE,
+        stderr=subprocess.STDOUT,
+        encoding="utf-8",
+    ) as process:
+        quit_requested = False
+        for line in process.stdout:
+            words = line.split(" ")
+            if len(words) > 1 and words[1] == "E]":
+                print(line, end="")
+            elif "Quit requested by a Lua script" in line:
+                quit_requested = True
+            elif "TEST_START" in line:
+                w = line.split("TEST_START")[1].split("\t")
+                print(f"TEST {w[2].strip()}\t\t", end="")
+            elif "TEST_OK" in line:
+                print(f"OK")
+            elif "TEST_FAILED" in line:
+                w = line.split("TEST_FAILED")[1].split("\t")
+                print(f"FAILED {w[3]}\t\t")
+        process.wait(5)
+        if not quit_requested:
+            print("ERROR: Unexpected termination")
+    shutil.copyfile(config_dir / "openmw.log", work_dir / f"{name}.{time_str}.log")
+    print(f"{name} finished")
+
+
+for entry in tests_dir.glob("test_*"):
+    if entry.is_dir():
+        runTest(entry.name)
+shutil.rmtree(config_dir, ignore_errors=True)
+shutil.rmtree(userdata_dir, ignore_errors=True)
+