[Scummvm-git-logs] scummvm master -> dc7ddeb4bdcd7e54ad6c2778e08012fdfdf76aa2

npjg noreply at scummvm.org
Sun Jul 19 01:01:25 UTC 2026


This automated email contains information about 13 new commits which have been
pushed to the 'scummvm' repo located at https://api.github.com/repos/scummvm/scummvm .

Summary:
5a6d6f450f MEDIASTATION: Move Lion King constellations minigame to its own folder
b12ec7e8b4 MEDIASTATION: Implement mouse position update methods
afff90261d MEDIASTATION: Implement hardcoded MoveSophie function for Puzzle Castle
64a9d13066 MEDIASTATION: Warn on duplicate script responses
384719a0ed MEDIASTATION: Implement weird Puzzle Castle override for transitions
f3ed45542d MEDIASTATION: Fix incorrect dissolve factor massaging
3ab53f3f68 MEDIASTATION: Draw uncompressed bitmaps by their width, not stride
d44b033f89 MEDIASTATION: Stub out printer actor
0fcbd2cc2c MEDIASTATION: Get text input working for Puzzle Castle
e608521bed MEDIASTATION: Implement key events on hotspots
0591485c99 MEDIASTATION: Implement hardcoded stalking minigame for Lion King
edb7728208 MEDIASTATION: Implement hardcoded Dalmatians maze minigame
dc7ddeb4bd MEDIASTATION: Implement hardcoded checkers minigame for Hercules


Commit: 5a6d6f450f4fc7b33716f640a39700b6f13009eb
    https://github.com/scummvm/scummvm/commit/5a6d6f450f4fc7b33716f640a39700b6f13009eb
Author: Nathanael Gentry (nathanael.gentrydb8 at gmail.com)
Date: 2026-07-18T19:55:59-04:00

Commit Message:
MEDIASTATION: Move Lion King constellations minigame to its own folder

Changed paths:
  A engines/mediastation/minigames/dotgame.cpp
  A engines/mediastation/minigames/dotgame.h
  R engines/mediastation/actors/dotgame.cpp
  R engines/mediastation/actors/dotgame.h
    engines/mediastation/context.cpp
    engines/mediastation/module.mk


diff --git a/engines/mediastation/context.cpp b/engines/mediastation/context.cpp
index bb6f7be1994..a9b584617df 100644
--- a/engines/mediastation/context.cpp
+++ b/engines/mediastation/context.cpp
@@ -30,7 +30,6 @@
 #include "mediastation/actors/canvas.h"
 #include "mediastation/actors/cursor.h"
 #include "mediastation/actors/diskimage.h"
-#include "mediastation/actors/dotgame.h"
 #include "mediastation/actors/palette.h"
 #include "mediastation/actors/image.h"
 #include "mediastation/actors/path.h"
@@ -43,6 +42,7 @@
 #include "mediastation/actors/screen.h"
 #include "mediastation/actors/font.h"
 #include "mediastation/actors/text.h"
+#include "mediastation/minigames/dotgame.h"
 
 namespace MediaStation {
 
diff --git a/engines/mediastation/actors/dotgame.cpp b/engines/mediastation/minigames/dotgame.cpp
similarity index 99%
rename from engines/mediastation/actors/dotgame.cpp
rename to engines/mediastation/minigames/dotgame.cpp
index d27e709efc5..98aaa285491 100644
--- a/engines/mediastation/actors/dotgame.cpp
+++ b/engines/mediastation/minigames/dotgame.cpp
@@ -19,13 +19,13 @@
  *
  */
 
-#include "mediastation/actors/dotgame.h"
+#include "graphics/paletteman.h"
+
+#include "mediastation/minigames/dotgame.h"
 #include "mediastation/datafile.h"
 #include "mediastation/debugchannels.h"
 #include "mediastation/mediastation.h"
 
-#include "graphics/paletteman.h"
-
 namespace MediaStation {
 
 void DotGameActor::readParameter(Chunk &chunk, ActorHeaderSectionType paramType) {
diff --git a/engines/mediastation/actors/dotgame.h b/engines/mediastation/minigames/dotgame.h
similarity index 95%
rename from engines/mediastation/actors/dotgame.h
rename to engines/mediastation/minigames/dotgame.h
index 53a6a5b49c3..89716f426b4 100644
--- a/engines/mediastation/actors/dotgame.h
+++ b/engines/mediastation/minigames/dotgame.h
@@ -19,8 +19,8 @@
  *
  */
 
-#ifndef MEDIASTATION_DOTGAME_H
-#define MEDIASTATION_DOTGAME_H
+#ifndef MEDIASTATION_MINIGAMES_DOTGAME_H
+#define MEDIASTATION_MINIGAMES_DOTGAME_H
 
 #include "mediastation/actors/stage.h"
 
diff --git a/engines/mediastation/module.mk b/engines/mediastation/module.mk
index 5761a2fcbf6..2dc80d3af4f 100644
--- a/engines/mediastation/module.mk
+++ b/engines/mediastation/module.mk
@@ -7,7 +7,6 @@ MODULE_OBJS = \
 	actors/cursor.o \
 	actors/diskimage.o \
 	actors/document.o \
-	actors/dotgame.o \
 	actors/font.o \
 	actors/hotspot.o \
 	actors/image.o \
@@ -38,6 +37,7 @@ MODULE_OBJS = \
 	mediascript/scriptvalue.o \
 	mediastation.o \
 	metaengine.o \
+	minigames/dotgame.o \
 	profile.o
 
 # This module can be built as a plugin


Commit: b12ec7e8b4e6c1a88a65b9164ad3e3cbffbf978e
    https://github.com/scummvm/scummvm/commit/b12ec7e8b4e6c1a88a65b9164ad3e3cbffbf978e
Author: Nathanael Gentry (nathanael.gentrydb8 at gmail.com)
Date: 2026-07-18T19:55:59-04:00

Commit Message:
MEDIASTATION: Implement mouse position update methods

Changed paths:
    engines/mediastation/actors/stage.cpp
    engines/mediastation/events.cpp
    engines/mediastation/mediastation.h


diff --git a/engines/mediastation/actors/stage.cpp b/engines/mediastation/actors/stage.cpp
index 3b10b0f28b5..274a28b76fa 100644
--- a/engines/mediastation/actors/stage.cpp
+++ b/engines/mediastation/actors/stage.cpp
@@ -783,7 +783,7 @@ void RootStage::deleteChildrenFromContextId(uint contextId) {
 void RootStage::setMousePosition(int16 x, int16 y) {
 	x += _boundingBox.left;
 	y += _boundingBox.top;
-	warning("[%s] %s: STUB: (%d, %d)", debugName(), __func__, x, y);
+	g_system->warpMouse(x, y);
 }
 
 StageDirector::StageDirector() {
diff --git a/engines/mediastation/events.cpp b/engines/mediastation/events.cpp
index 1a3ff6b403c..d5305ebd5ee 100644
--- a/engines/mediastation/events.cpp
+++ b/engines/mediastation/events.cpp
@@ -238,6 +238,15 @@ void MediaStationEngine::dispatchOneSystemEvent(const Common::Event &event) {
 	}
 }
 
+void MediaStationEngine::generateMouseUpdateEvent() {
+	queueMouseEvent(kMouseEnterExitEvent, g_system->getEventManager()->getMousePos());
+}
+
+void MediaStationEngine::queueMouseEvent(EventType type, const Common::Point &point) {
+	MouseEvent mouseEvent(type, point);
+	_eventLoop->queueEvent(mouseEvent);
+}
+
 PreDisplaySyncState EventLoop::preDisplaySync() {
 	PreDisplaySyncState state = kPreDisplaySyncNoScreenUpdateRequested;
 	for (auto it = _preDisplaySyncClients.begin(); it != _preDisplaySyncClients.end(); ++it) {
diff --git a/engines/mediastation/mediastation.h b/engines/mediastation/mediastation.h
index f3fd80c5091..f5590256eed 100644
--- a/engines/mediastation/mediastation.h
+++ b/engines/mediastation/mediastation.h
@@ -87,6 +87,7 @@ public:
 	const char *getAppName() const;
 	bool hasFeature(EngineFeature f) const override;
 	void dispatchOneSystemEvent(const Common::Event &event);
+	void generateMouseUpdateEvent();
 
 	VideoDisplayManager *getDisplayManager() { return _displayManager; }
 	DisplayUpdateManager *getDisplayUpdateManager() { return _displayUpdateManager; }


Commit: afff90261d84f73234212a0e6888345da92c7385
    https://github.com/scummvm/scummvm/commit/afff90261d84f73234212a0e6888345da92c7385
Author: Nathanael Gentry (nathanael.gentrydb8 at gmail.com)
Date: 2026-07-18T19:55:59-04:00

Commit Message:
MEDIASTATION: Implement hardcoded MoveSophie function for Puzzle Castle

Assisted-by: gpt-5.5 claude-sonnet-4.5

Changed paths:
  A engines/mediastation/minigames/sophie.cpp
    engines/mediastation/mediascript/function.cpp
    engines/mediastation/mediascript/function.h
    engines/mediastation/mediascript/scriptconstants.h
    engines/mediastation/module.mk


diff --git a/engines/mediastation/mediascript/function.cpp b/engines/mediastation/mediascript/function.cpp
index 8fe2133f552..df491de1b85 100644
--- a/engines/mediastation/mediascript/function.cpp
+++ b/engines/mediastation/mediascript/function.cpp
@@ -253,6 +253,11 @@ ScriptValue FunctionManager::call(uint functionId, Common::Array<ScriptValue> &a
 		script_Checkers(args, returnValue);
 		break;
 
+	case kMoveSophieFunction:
+		FUNCARGCHECK(14);
+		script_MoveSophie(args, returnValue);
+		break;
+
 	case kLegacy_DebugPrintFunction:
 		// We don't need to check arg counts here. This just prints however many args we have.
 		script_DebugPrint(args, returnValue);
diff --git a/engines/mediastation/mediascript/function.h b/engines/mediastation/mediascript/function.h
index 1da34af9610..adf2c16127b 100644
--- a/engines/mediastation/mediascript/function.h
+++ b/engines/mediastation/mediascript/function.h
@@ -100,6 +100,9 @@ private:
 
 	// IBM/Crayola.
 	void script_Drawing(Common::Array<ScriptValue> &args, ScriptValue &returnValue);
+
+	// Puzzle Castle.
+	void script_MoveSophie(Common::Array<ScriptValue> &args, ScriptValue &returnValue);
 };
 
 } // End of namespace MediaStation
diff --git a/engines/mediastation/mediascript/scriptconstants.h b/engines/mediastation/mediascript/scriptconstants.h
index 146ef5f1b32..7fcc8d35de7 100644
--- a/engines/mediastation/mediascript/scriptconstants.h
+++ b/engines/mediastation/mediascript/scriptconstants.h
@@ -97,6 +97,7 @@ enum BuiltInFunction {
 	kEndTimedIntervalFunction = 0x23,
 	kCheckersFunction = 0x24, // Hercules
 	kDrawingFunction = 0x25, // IBM/Crayola
+	kMoveSophieFunction = 0x44c, // Puzzle Castle
 
 	// Early engine versions (like for Lion King and such), had different opcodes
 	// for some functions, even though the functions were the same. So those are
diff --git a/engines/mediastation/minigames/sophie.cpp b/engines/mediastation/minigames/sophie.cpp
new file mode 100644
index 00000000000..8a0a1aa1cc0
--- /dev/null
+++ b/engines/mediastation/minigames/sophie.cpp
@@ -0,0 +1,87 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "mediastation/mediascript/function.h"
+#include "mediastation/actors/sprite.h"
+#include "mediastation/mediastation.h"
+#include "math/angle.h"
+
+namespace MediaStation {
+
+// This is a hardcoded function in Puzzle Castle.
+void FunctionManager::script_MoveSophie(Common::Array<ScriptValue> &args, ScriptValue &returnValue) {
+	// This seems to be hardcoded rather than implemented as a script function due to the need for atan2l.
+	uint sophieActorId = args[0].asActorId();
+	SpriteMovieActor *spriteActor = static_cast<SpriteMovieActor *>(g_engine->getImtGod()->getActorByIdAndType(sophieActorId, kActorTypeSprite));
+
+	// This seems to be some sort of angle perhaps, but it is not used.
+	// int unk1 = static_cast<int>(args[1].asFloat());
+	int dx = static_cast<int>(args[2].asFloat());
+	int dy = static_cast<int>(args[3].asFloat());
+	// args[4] through args[11] are the sprite clip ids. They typically should be:
+	// args[4] = $sophieWalk_North (10018),
+	// args[5] = $sophieWalk_NorthEast (10021),
+	// args[6] = $sophieWalk_East (10020),
+	// args[7] = $sophieWalk_SouthEast (10022),
+	// args[8] = $sophieWalk_South (10019),
+	// args[9] = $sophieWalk_SouthWest (10025),
+	// args[10] = $sophieWalk_West (10023),
+	// args[11] = $sophieWalk_NorthWest (10024)
+	double sophieSpatialCenterX = args[12].asFloat();
+	double sophieSpatialCenterY = args[13].asFloat();
+
+	// Convert the direction we're moving into an octant to select the proper sprite to show.
+	const float angleDegrees = Math::Angle::arcTangent2(dy, dx).getDegrees(0);
+	int octant = int((angleDegrees + 22.5f) / 45.0f) & 7;
+
+	// Get that sprite clip.
+	Common::Array<ScriptValue> sophieArgs;
+	ScriptValue temp;
+	static const int octantToScriptArgIndex[] = {
+		6,  // East
+		5,  // NE
+		4,  // North
+		11, // NW
+		10, // West
+		9,  // SW
+		8,  // South
+		7   // SE
+	};
+	int directionIndex = octantToScriptArgIndex[octant];
+	sophieArgs.push_back(args[directionIndex]);
+	spriteActor->callMethod(kSetCurrentClipMethod, sophieArgs);
+
+	// Move Sophie to the right location.
+	sophieArgs.clear();
+	temp.setToFloat(sophieSpatialCenterX);
+	sophieArgs.push_back(temp);
+	temp.setToFloat(sophieSpatialCenterY);
+	sophieArgs.push_back(temp);
+	spriteActor->callMethod(kSpatialCenterMoveToMethod, sophieArgs);
+
+	// Increment the frame to show Sophie walking.
+	sophieArgs.clear();
+	temp.setToBool(true);
+	sophieArgs.push_back(temp);
+	spriteActor->callMethod(kIncrementFrameMethod, sophieArgs);
+}
+
+} // End of namespace Mediastation
diff --git a/engines/mediastation/module.mk b/engines/mediastation/module.mk
index 2dc80d3af4f..102946cb517 100644
--- a/engines/mediastation/module.mk
+++ b/engines/mediastation/module.mk
@@ -38,6 +38,7 @@ MODULE_OBJS = \
 	mediastation.o \
 	metaengine.o \
 	minigames/dotgame.o \
+	minigames/sophie.o \
 	profile.o
 
 # This module can be built as a plugin


Commit: 64a9d13066a8c58c813b85eead4fae594ef07f12
    https://github.com/scummvm/scummvm/commit/64a9d13066a8c58c813b85eead4fae594ef07f12
Author: Nathanael Gentry (nathanael.gentrydb8 at gmail.com)
Date: 2026-07-18T19:55:59-04:00

Commit Message:
MEDIASTATION: Warn on duplicate script responses

Not throwing an error is required for the Puzzle Castle demo to run.

Changed paths:
    engines/mediastation/actor.cpp


diff --git a/engines/mediastation/actor.cpp b/engines/mediastation/actor.cpp
index 534570433d2..d46e7210071 100644
--- a/engines/mediastation/actor.cpp
+++ b/engines/mediastation/actor.cpp
@@ -167,7 +167,7 @@ void Actor::readParameter(Chunk &chunk, ActorHeaderSectionType paramType) {
 		// This is not a hashmap because we don't want to have to hash ScriptValues.
 		for (ScriptResponse *existingScriptResponse : scriptResponsesForType) {
 			if (existingScriptResponse->_argumentValue == scriptResponse->_argumentValue) {
-				error("[%s] %s: Script response for %s (%s) already exists", debugName(), __func__,
+				warning("[%s] %s: Script response for %s (%s) already exists", debugName(), __func__,
 					eventTypeToStr(scriptResponse->_type), scriptResponse->_argumentValue.getDebugString().c_str());
 			}
 		}


Commit: 384719a0edee6bfa53f33924cb99faddf42d3dba
    https://github.com/scummvm/scummvm/commit/384719a0edee6bfa53f33924cb99faddf42d3dba
Author: Nathanael Gentry (nathanael.gentrydb8 at gmail.com)
Date: 2026-07-18T19:55:59-04:00

Commit Message:
MEDIASTATION: Implement weird Puzzle Castle override for transitions

Changed paths:
    engines/mediastation/mediascript/function.cpp
    engines/mediastation/mediascript/function.h
    engines/mediastation/mediastation.cpp
    engines/mediastation/mediastation.h


diff --git a/engines/mediastation/mediascript/function.cpp b/engines/mediastation/mediascript/function.cpp
index df491de1b85..fac73f8f6c3 100644
--- a/engines/mediastation/mediascript/function.cpp
+++ b/engines/mediastation/mediascript/function.cpp
@@ -145,7 +145,7 @@ ScriptValue FunctionManager::call(uint functionId, Common::Array<ScriptValue> &a
 
 	case kEffectTransitionFunction:
 	case kLegacy_EffectTransitionFunction:
-		g_engine->getDisplayManager()->effectTransition(args);
+		script_EffectTransition(args, returnValue);
 		break;
 
 	case kEffectTransitionOnSyncFunction:
@@ -371,13 +371,29 @@ void FunctionManager::script_Random(Common::Array<ScriptValue> &args, ScriptValu
 }
 
 void FunctionManager::script_TimeOfDay(Common::Array<ScriptValue> &args, ScriptValue &returnValue) {
-	TimeDate timeDate;
 	// Calculate seconds since midnight.
-	g_system->getTimeAndDate(timeDate);
-	uint32 secondsSinceMidnight = (timeDate.tm_hour * 60 + timeDate.tm_min) * 60 + timeDate.tm_sec;
+	uint secondsSinceMidnight = g_engine->currentTimeInSeconds();
 	returnValue.setToTime(static_cast<double>(secondsSinceMidnight));
 }
 
+void FunctionManager::script_EffectTransition(Common::Array<ScriptValue> &args, ScriptValue &returnValue) {
+	// Puzzle Castle has this weird code path where it checks for this magic value.
+	// If so, it doesn't actually do the transition. Otherwise, we just do a normal
+	// transition. Scripts rely on this behavior, so we must reimplement it.
+	bool triggerPuzzleCastleIterationUpdate = (args.size() == 2) && (args[0].asFloat() == 9999);
+	if (triggerPuzzleCastleIterationUpdate) {
+		uint iterations = static_cast<uint>(args[1].asFloat());
+		uint start = g_engine->currentTimeInSeconds();
+		for (unsigned i = 0; i < iterations; ++i) {
+			g_engine->getDisplayUpdateManager()->performUpdateAll();
+		}
+
+		returnValue.setToFloat(g_engine->currentTimeInSeconds() - start);
+	} else {
+		g_engine->getDisplayManager()->effectTransition(args);
+	}
+}
+
 void FunctionManager::script_SquareRoot(Common::Array<ScriptValue> &args, ScriptValue &returnValue) {
 	if (args[0].getType() != kScriptValueTypeFloat) {
 		error("%s: Numeric value required", __func__);
diff --git a/engines/mediastation/mediascript/function.h b/engines/mediastation/mediascript/function.h
index adf2c16127b..fc7432870e8 100644
--- a/engines/mediastation/mediascript/function.h
+++ b/engines/mediastation/mediascript/function.h
@@ -87,6 +87,7 @@ private:
 	void script_GetRegistry(Common::Array<ScriptValue> &args, ScriptValue &returnValue);
 	void script_SetProfile(Common::Array<ScriptValue> &args, ScriptValue &returnValue);
 	void script_DebugPrint(Common::Array<ScriptValue> &args, ScriptValue &returnValue);
+	void script_EffectTransition(Common::Array<ScriptValue> &args, ScriptValue &returnValue);
 
 	// 101 Dalmatians.
 	void script_MazeGenerate(Common::Array<ScriptValue> &args, ScriptValue &returnValue);
diff --git a/engines/mediastation/mediastation.cpp b/engines/mediastation/mediastation.cpp
index 8b2bcb94e69..7e510b8fb46 100644
--- a/engines/mediastation/mediastation.cpp
+++ b/engines/mediastation/mediastation.cpp
@@ -142,6 +142,14 @@ bool MediaStationEngine::hasFeature(EngineFeature f) const {
 	return (f == kSupportsReturnToLauncher);
 }
 
+uint MediaStationEngine::currentTimeInSeconds() {
+	// Calculate seconds since midnight.
+	TimeDate timeDate;
+	g_system->getTimeAndDate(timeDate);
+	uint secondsSinceMidnight = (timeDate.tm_hour * 60 + timeDate.tm_min) * 60 + timeDate.tm_sec;
+	return secondsSinceMidnight;
+}
+
 Common::Error MediaStationEngine::run() {
 	_debugger = new Debugger(this);
 	setDebugger(_debugger);
diff --git a/engines/mediastation/mediastation.h b/engines/mediastation/mediastation.h
index f5590256eed..03035f50e0f 100644
--- a/engines/mediastation/mediastation.h
+++ b/engines/mediastation/mediastation.h
@@ -88,6 +88,7 @@ public:
 	bool hasFeature(EngineFeature f) const override;
 	void dispatchOneSystemEvent(const Common::Event &event);
 	void generateMouseUpdateEvent();
+	uint currentTimeInSeconds();
 
 	VideoDisplayManager *getDisplayManager() { return _displayManager; }
 	DisplayUpdateManager *getDisplayUpdateManager() { return _displayUpdateManager; }


Commit: f3ed45542d0e74b3d56cc03bfcc2c030374ee944
    https://github.com/scummvm/scummvm/commit/f3ed45542d0e74b3d56cc03bfcc2c030374ee944
Author: Nathanael Gentry (nathanael.gentrydb8 at gmail.com)
Date: 2026-07-18T19:55:59-04:00

Commit Message:
MEDIASTATION: Fix incorrect dissolve factor massaging

Changed paths:
    engines/mediastation/graphics.cpp


diff --git a/engines/mediastation/graphics.cpp b/engines/mediastation/graphics.cpp
index 1b6cff89829..c8594d3f58b 100644
--- a/engines/mediastation/graphics.cpp
+++ b/engines/mediastation/graphics.cpp
@@ -884,7 +884,9 @@ void VideoDisplayManager::imageBlit(
 	if (dissolveFactor > 1.0 || dissolveFactor < 0.0) {
 		warning("%s: Got out-of-range dissolve factor: %f", __func__, dissolveFactor);
 		dissolveFactor = CLIP(dissolveFactor, 0.0, 1.0);
-	} else if (dissolveFactor == 0.0) {
+	}
+
+	if (dissolveFactor == 0.0) {
 		// If the image is fully transparent, there is nothing to draw, so we can return now.
 		return;
 	} else if (dissolveFactor != 1.0) {
@@ -1042,11 +1044,11 @@ void VideoDisplayManager::dissolveBlitRectsClip(
 	const Common::Array<Common::Rect> &dirtyRegion,
 	const uint integralDissolveFactor) {
 
-	byte dissolveIndex = DISSOLVE_PATTERN_COUNT;
+	byte dissolveIndex = DISSOLVE_PATTERN_COUNT - 1;
 	if (integralDissolveFactor != 50) {
-		dissolveIndex = ((integralDissolveFactor + 2) / 4) - 1;
+		dissolveIndex = ((integralDissolveFactor + 2) / 4);
+		dissolveIndex = CLIP<byte>(dissolveIndex, 0, (DISSOLVE_PATTERN_COUNT - 2));
 	}
-	dissolveIndex = CLIP<byte>(dissolveIndex, 0, (DISSOLVE_PATTERN_COUNT - 1));
 
 	Common::Rect destRect(Common::Rect(destPos, source->width(), source->height()));
 	for (const Common::Rect &dirtyRect : dirtyRegion) {


Commit: 3ab53f3f685b17488cf7e6692f3beb2ba7514837
    https://github.com/scummvm/scummvm/commit/3ab53f3f685b17488cf7e6692f3beb2ba7514837
Author: Nathanael Gentry (nathanael.gentrydb8 at gmail.com)
Date: 2026-07-18T19:55:59-04:00

Commit Message:
MEDIASTATION: Draw uncompressed bitmaps by their width, not stride

In the vast majority of cases, this caused no problems. However,
there were a few sprites in Puzzle Castle that drew garbage on
their edges when drawing with stride.

Changed paths:
    engines/mediastation/graphics.cpp
    engines/mediastation/graphics.h


diff --git a/engines/mediastation/graphics.cpp b/engines/mediastation/graphics.cpp
index c8594d3f58b..ee127147341 100644
--- a/engines/mediastation/graphics.cpp
+++ b/engines/mediastation/graphics.cpp
@@ -923,7 +923,7 @@ void VideoDisplayManager::imageBlit(
 		// non-transparent blitting, but we will just use simpleBlitFrom in both
 		// cases. It will pick the better method if there is no transparent
 		// color set.
-		blitRectsClip(targetImage, destinationPoint, sourceImage->_image, dirtyRegion, useTransBlit);
+		blitRectsClip(targetImage, destinationPoint, sourceImage, dirtyRegion, useTransBlit);
 		break;
 
 	case kRle8Blit | kClipEnabled:
@@ -956,12 +956,12 @@ void VideoDisplayManager::imageBlit(
 void VideoDisplayManager::blitRectsClip(
 	Graphics::ManagedSurface *dest,
 	const Common::Point &destLocation,
-	const Graphics::ManagedSurface &source,
+	const PixMapImage *source,
 	const Common::Array<Common::Rect> &dirtyRegion,
 	bool useTransBlit) {
 
 	for (const Common::Rect &dirtyRect : dirtyRegion) {
-		Common::Rect destRect(destLocation, source.w, source.h);
+		Common::Rect destRect(destLocation, source->width(), source->height());
 		Common::Rect areaToRedraw = dirtyRect.findIntersectingRect(destRect);
 
 		if (!areaToRedraw.isEmpty()) {
@@ -969,9 +969,9 @@ void VideoDisplayManager::blitRectsClip(
 			Common::Point originOnScreen(areaToRedraw.origin());
 			areaToRedraw.translate(-destLocation.x, -destLocation.y);
 			if (useTransBlit) {
-				dest->transBlitFrom(source, areaToRedraw, originOnScreen);
+				dest->transBlitFrom(source->_image, areaToRedraw, originOnScreen);
 			} else {
-				dest->simpleBlitFrom(source, areaToRedraw, originOnScreen);
+				dest->simpleBlitFrom(source->_image, areaToRedraw, originOnScreen);
 			}
 		}
 	}
diff --git a/engines/mediastation/graphics.h b/engines/mediastation/graphics.h
index c9cefa80b3f..844600c3411 100644
--- a/engines/mediastation/graphics.h
+++ b/engines/mediastation/graphics.h
@@ -243,7 +243,7 @@ private:
 	void blitRectsClip(
 		Graphics::ManagedSurface *dest,
 		const Common::Point &destLocation,
-		const Graphics::ManagedSurface &source,
+		const PixMapImage *source,
 		const Common::Array<Common::Rect> &dirtyRegion,
 		bool useTransBlit = false);
 	void rleBlitRectsClip(


Commit: d44b033f893b4399b3db41f8c03831deacc47d43
    https://github.com/scummvm/scummvm/commit/d44b033f893b4399b3db41f8c03831deacc47d43
Author: Nathanael Gentry (nathanael.gentrydb8 at gmail.com)
Date: 2026-07-18T19:55:59-04:00

Commit Message:
MEDIASTATION: Stub out printer actor

Having a printer actor is necessary to get to the last screen in Puzzle Castle,
even if nothing is printed from there.

Changed paths:
  A engines/mediastation/actors/printer.cpp
  A engines/mediastation/actors/printer.h
    engines/mediastation/context.cpp
    engines/mediastation/graphics.cpp
    engines/mediastation/graphics.h
    engines/mediastation/mediascript/scriptconstants.cpp
    engines/mediastation/mediascript/scriptconstants.h
    engines/mediastation/mediastation.cpp
    engines/mediastation/mediastation.h
    engines/mediastation/module.mk


diff --git a/engines/mediastation/actors/printer.cpp b/engines/mediastation/actors/printer.cpp
new file mode 100644
index 00000000000..8973add3165
--- /dev/null
+++ b/engines/mediastation/actors/printer.cpp
@@ -0,0 +1,150 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "mediastation/actors/printer.h"
+#include "mediastation/graphics.h"
+#include "mediastation/mediastation.h"
+
+namespace MediaStation {
+
+ScriptValue PrinterActor::callMethod(BuiltInMethod methodId, Common::Array<ScriptValue> &args) {
+	ScriptValue returnValue;
+	switch (methodId) {
+	case kIsPrinterReadyMethod:
+		ARGCOUNTCHECK(0);
+		returnValue.setToBool(g_engine->getPrintManager()->printerIsReady());
+		break;
+
+	case kPrintActorsMethod:
+		ARGCOUNTCHECK(1);
+		if (args[0].getType() == kScriptValueTypeActorId) {
+			const uint actorId = args[0].asActorId();
+			if (printActor(actorId, true)) {
+				g_engine->getPrintManager()->flushToPrinter();
+			}
+		} else if (args[0].getType() == kScriptValueTypeCollection) {
+			const Collection *actorCollection = args[0].asCollection();
+			if (actorCollection != nullptr) {
+				printActorCollection(*actorCollection);
+			}
+		}
+		break;
+
+	case kPrintScreenMethod:
+		ARGCOUNTCHECK(0);
+		g_engine->getPrintManager()->printScreen();
+		break;
+
+	case kSetPortraitPrintMethod:
+		ARGCOUNTCHECK(1);
+		if (args[0].asString().equalsIgnoreCase("PORTRAIT")) {
+			g_engine->getPrintManager()->_isPortrait = true;
+		} else if (args[0].asString().equalsIgnoreCase("LANDSCAPE")) {
+			g_engine->getPrintManager()->_isPortrait = false;
+		}
+		break;
+
+	case kGetPortraitPrintMethod:
+		ARGCOUNTCHECK(0);
+		returnValue.setToString(g_engine->getPrintManager()->_isPortrait ? "PORTRAIT" : "LANDSCAPE");
+		break;
+
+	case kSetLeftPrintMarginMethod:
+		ARGCOUNTCHECK(1);
+		g_engine->getPrintManager()->_leftMargin = args[0].asFloat();
+		break;
+
+	case kSetTopPrintMarginMethod:
+		ARGCOUNTCHECK(1);
+		g_engine->getPrintManager()->_topMargin = args[0].asFloat();
+		break;
+
+	case kSetRightPrintMarginMethod:
+		ARGCOUNTCHECK(1);
+		g_engine->getPrintManager()->_rightMargin = args[0].asFloat();
+		break;
+
+	case kSetBottomPrintMarginMethod:
+		ARGCOUNTCHECK(1);
+		g_engine->getPrintManager()->_bottomMargin = args[0].asFloat();
+		break;
+
+	case kGetLeftPrintMarginMethod:
+		ARGCOUNTCHECK(0);
+		returnValue.setToFloat(g_engine->getPrintManager()->_leftMargin);
+		break;
+
+	case kGetTopPrintMarginMethod:
+		ARGCOUNTCHECK(0);
+		returnValue.setToFloat(g_engine->getPrintManager()->_topMargin);
+		break;
+
+	case kGetRightPrintMarginMethod:
+		ARGCOUNTCHECK(0);
+		returnValue.setToFloat(g_engine->getPrintManager()->_rightMargin);
+		break;
+
+	case kGetBottomPrintMarginMethod:
+		ARGCOUNTCHECK(0);
+		returnValue.setToFloat(g_engine->getPrintManager()->_bottomMargin);
+		break;
+
+	default:
+		returnValue = SpatialEntity::callMethod(methodId, args);
+		break;
+	}
+
+	return returnValue;
+}
+
+bool PrinterActor::printActor(uint actorId, bool unk1) {
+	if (!unk1) {
+		return false;
+	}
+
+	SpatialEntity *entityToPrint = g_engine->getImtGod()->getSpatialEntityById(actorId);
+	if (entityToPrint == nullptr) {
+		warning("[%s] %s: Actor %u is not spatial and cannot be printed", debugName(), __func__, actorId);
+		return false;
+	}
+
+	PrintManager *printManager = g_engine->getPrintManager();
+	if (printManager == nullptr) {
+		warning("[%s] %s: Print manager has not been initialized", debugName(), __func__);
+		return false;
+	}
+
+	printManager->printSpatialObject(entityToPrint);
+	return true;
+}
+
+void PrinterActor::printActorCollection(const Collection &actors) {
+	for (const ScriptValue &item : actors) {
+		if (item.getType() != kScriptValueTypeActorId) {
+			continue;
+		}
+
+		const uint actorId = item.asActorId();
+		printActor(actorId, true);
+	}
+}
+
+} // End of namespace MediaStation
diff --git a/engines/mediastation/actors/printer.h b/engines/mediastation/actors/printer.h
new file mode 100644
index 00000000000..2a751d238c4
--- /dev/null
+++ b/engines/mediastation/actors/printer.h
@@ -0,0 +1,42 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef MEDIASTATION_ACTORS_PRINTER_H
+#define MEDIASTATION_ACTORS_PRINTER_H
+
+#include "mediastation/actor.h"
+
+namespace MediaStation {
+
+class PrinterActor : public SpatialEntity {
+public:
+	PrinterActor() : SpatialEntity(kActorTypePrinter) {};
+	virtual ScriptValue callMethod(BuiltInMethod methodId, Common::Array<ScriptValue> &args) override;
+
+private:
+	bool printActor(uint actorId, bool unk1);
+	void printActorCollection(const Collection &actors);
+
+};
+
+} // End of namespace MediaStation
+
+#endif
diff --git a/engines/mediastation/context.cpp b/engines/mediastation/context.cpp
index a9b584617df..5fbd3532440 100644
--- a/engines/mediastation/context.cpp
+++ b/engines/mediastation/context.cpp
@@ -33,6 +33,7 @@
 #include "mediastation/actors/palette.h"
 #include "mediastation/actors/image.h"
 #include "mediastation/actors/path.h"
+#include "mediastation/actors/printer.h"
 #include "mediastation/actors/sound.h"
 #include "mediastation/actors/movie.h"
 #include "mediastation/actors/sprite.h"
@@ -166,6 +167,10 @@ void ImtGod::readCreateActorData(Chunk &chunk) {
 		actor = new DiskImageActor();
 		break;
 
+	case kActorTypePrinter:
+		actor = new PrinterActor();
+		break;
+
 	case kActorTypeDotGame:
 		actor = new DotGameActor();
 		break;
diff --git a/engines/mediastation/graphics.cpp b/engines/mediastation/graphics.cpp
index ee127147341..23564470b41 100644
--- a/engines/mediastation/graphics.cpp
+++ b/engines/mediastation/graphics.cpp
@@ -1503,4 +1503,25 @@ void VideoDisplayManager::getGammaValues(double &red, double &green, double &blu
 	blue = _blueGamma;
 }
 
+bool PrintManager::printerIsReady() {
+	// For this stub, we will just always report that †he printer is ready.
+	return true;
+}
+
+void PrintManager::printScreen() {
+	warning("STUB: %s", __func__);
+}
+
+void PrintManager::printSpatialObject(SpatialEntity *entity) {
+	warning("STUB: %s", __func__);
+}
+
+void PrintManager::setSourceSize(Common::Point size) {
+	warning("STUB: %s", __func__);
+}
+
+void PrintManager::flushToPrinter() {
+	warning("STUB: %s", __func__);
+}
+
 } // End of namespace MediaStation
diff --git a/engines/mediastation/graphics.h b/engines/mediastation/graphics.h
index 844600c3411..609a107dfad 100644
--- a/engines/mediastation/graphics.h
+++ b/engines/mediastation/graphics.h
@@ -146,6 +146,23 @@ private:
 	Common::Stack<Clip> _clips;
 };
 
+
+class PrintManager : DisplayContext {
+public:
+	bool printerIsReady();
+	void printScreen();
+	void printSpatialObject(SpatialEntity *entity);
+	void setSourceSize(Common::Point size);
+	void flushToPrinter();
+
+	bool _isPortrait = false;
+
+	double _leftMargin = -1;
+	double _rightMargin = -1;
+	double _topMargin = -1;
+	double _bottomMargin = -1;
+};
+
 class DisplayUpdateManager {
 public:
 	virtual ~DisplayUpdateManager() {}
diff --git a/engines/mediastation/mediascript/scriptconstants.cpp b/engines/mediastation/mediascript/scriptconstants.cpp
index abec7d8f86e..a0a89b54d6d 100644
--- a/engines/mediastation/mediascript/scriptconstants.cpp
+++ b/engines/mediastation/mediascript/scriptconstants.cpp
@@ -269,6 +269,32 @@ const char *builtInMethodToStr(BuiltInMethod method) {
 		return "StartCaching";
 	case kIsCachingMethod:
 		return "IsCaching";
+	case kIsPrinterReadyMethod:
+		return "IsPrinterReady";
+	case kPrintActorsMethod:
+		return "PrintActors";
+	case kPrintScreenMethod:
+		return "PrintScreen";
+	case kSetPortraitPrintMethod:
+		return "SetPortraitPrint";
+	case kGetPortraitPrintMethod:
+		return "GetPortraitPrint";
+	case kSetLeftPrintMarginMethod:
+		return "SetLeftPrintMargin";
+	case kSetTopPrintMarginMethod:
+		return "SetTopPrintMargin";
+	case kSetRightPrintMarginMethod:
+		return "SetRightPrintMargin";
+	case kSetBottomPrintMarginMethod:
+		return "SetBottomPrintMargin";
+	case kGetLeftPrintMarginMethod:
+		return "GetLeftPrintMargin";
+	case kGetTopPrintMarginMethod:
+		return "GetTopPrintMargin";
+	case kGetRightPrintMarginMethod:
+		return "GetRightPrintMargin";
+	case kGetBottomPrintMarginMethod:
+		return "GetBottomPrintMargin";
 	case kIsPausedMethod:
 		return "SetMultipleSounds/IsPaused";
 	case kSetMousePositionMethod:
diff --git a/engines/mediastation/mediascript/scriptconstants.h b/engines/mediastation/mediascript/scriptconstants.h
index 7fcc8d35de7..07f433dbe2a 100644
--- a/engines/mediastation/mediascript/scriptconstants.h
+++ b/engines/mediastation/mediascript/scriptconstants.h
@@ -286,6 +286,19 @@ enum BuiltInMethod {
 	// between two camera methods and two printer methods.
 	kOpenLensMethod = 0x15A,
 	kCloseLensMethod = 0x15B,
+	kIsPrinterReadyMethod = 0x110,
+	kPrintActorsMethod = 0x111,
+	kPrintScreenMethod = 0x112,
+	kSetPortraitPrintMethod = 0x115,
+	kGetPortraitPrintMethod = 0x116,
+	kSetLeftPrintMarginMethod = 0x117,
+	kSetTopPrintMarginMethod = 0x118,
+	kSetRightPrintMarginMethod = 0x119,
+	kSetBottomPrintMarginMethod = 0x11a,
+	kGetLeftPrintMarginMethod = 0x11b,
+	kGetTopPrintMarginMethod = 0x11c,
+	kGetRightPrintMarginMethod = 0x11d,
+	kGetBottomPrintMarginMethod = 0x11e,
 
 	// CURSOR METHODS.
 	kCursorSetMethod = 0xC8,
diff --git a/engines/mediastation/mediastation.cpp b/engines/mediastation/mediastation.cpp
index 7e510b8fb46..8d756103dd7 100644
--- a/engines/mediastation/mediastation.cpp
+++ b/engines/mediastation/mediastation.cpp
@@ -65,7 +65,7 @@ MediaStationEngine::~MediaStationEngine() {
 	delete _displayManager;
 	delete _displayUpdateManager;
 	delete _functionManager;
-	// delete _printManager;
+	delete _printManager;
 	delete _imtGod;
 	// delete _streamProfiler;
 	delete _streamFeedManager;
@@ -163,7 +163,7 @@ Common::Error MediaStationEngine::run() {
 	_functionManager = new FunctionManager;
 	_displayUpdateManager = new DisplayUpdateManager;
 	_displayManager = new VideoDisplayManager(this);
-	// _printManager = new PrintManager;
+	_printManager = new PrintManager;
 	_document = new Document;
 	DocumentActor *documentActor = new DocumentActor;
 	_imtGod->addConstructedActor(documentActor);
diff --git a/engines/mediastation/mediastation.h b/engines/mediastation/mediastation.h
index 03035f50e0f..cb75da019af 100644
--- a/engines/mediastation/mediastation.h
+++ b/engines/mediastation/mediastation.h
@@ -92,6 +92,7 @@ public:
 
 	VideoDisplayManager *getDisplayManager() { return _displayManager; }
 	DisplayUpdateManager *getDisplayUpdateManager() { return _displayUpdateManager; }
+	PrintManager *getPrintManager() { return _printManager; }
 	CursorManager *getCursorManager() { return _cursorManager; }
 	FunctionManager *getFunctionManager() { return _functionManager; }
 	RootStage *getRootStage() { return _stageDirector->getRootStage(); }
@@ -148,7 +149,7 @@ private:
 	FunctionManager *_functionManager = nullptr;
 	DisplayUpdateManager *_displayUpdateManager = nullptr;
 	VideoDisplayManager *_displayManager = nullptr;
-	// PrintManager *_printManager = nullptr;
+	PrintManager *_printManager = nullptr;
 	Document *_document = nullptr;
 	CursorManager *_cursorManager = nullptr;
 	StageDirector *_stageDirector = nullptr;
diff --git a/engines/mediastation/module.mk b/engines/mediastation/module.mk
index 102946cb517..3352d0e9baf 100644
--- a/engines/mediastation/module.mk
+++ b/engines/mediastation/module.mk
@@ -13,6 +13,7 @@ MODULE_OBJS = \
 	actors/movie.o \
 	actors/palette.o \
 	actors/path.o \
+	actors/printer.o \
 	actors/screen.o \
 	actors/sound.o \
 	actors/sprite.o \


Commit: 0fcbd2cc2c3591fe3cd19d3b6df93b9d7d765ae1
    https://github.com/scummvm/scummvm/commit/0fcbd2cc2c3591fe3cd19d3b6df93b9d7d765ae1
Author: Nathanael Gentry (nathanael.gentrydb8 at gmail.com)
Date: 2026-07-18T19:55:59-04:00

Commit Message:
MEDIASTATION: Get text input working for Puzzle Castle

Assisted-by: gpt-5.5 claude-sonnet-4.5

Changed paths:
    engines/mediastation/actor.h
    engines/mediastation/actors/stage.cpp
    engines/mediastation/actors/stage.h
    engines/mediastation/actors/text.cpp
    engines/mediastation/actors/text.h
    engines/mediastation/debugchannels.h
    engines/mediastation/detection.cpp
    engines/mediastation/events.cpp
    engines/mediastation/events.h


diff --git a/engines/mediastation/actor.h b/engines/mediastation/actor.h
index e1c54463a1e..fe036b5f0fc 100644
--- a/engines/mediastation/actor.h
+++ b/engines/mediastation/actor.h
@@ -292,7 +292,7 @@ public:
 		MouseActorState &state,
 		bool clipMouseEvents) { return kNoFlag; }
 	virtual uint16 findActorToAcceptKeyboardEvents(
-		uint16 asciiCode,
+		uint16 charCode,
 		uint16 eventMask,
 		MouseActorState &state) { return kNoFlag; }
 
diff --git a/engines/mediastation/actors/stage.cpp b/engines/mediastation/actors/stage.cpp
index 274a28b76fa..707f7cb1415 100644
--- a/engines/mediastation/actors/stage.cpp
+++ b/engines/mediastation/actors/stage.cpp
@@ -613,13 +613,13 @@ uint16 StageActor::findActorToAcceptMouseEvents(
 }
 
 uint16 StageActor::findActorToAcceptKeyboardEvents(
-	uint16 asciiCode,
+	uint16 charCode,
 	uint16 eventMask,
 	MouseActorState &state) {
 
 	uint16 result = 0;
 	for (SpatialEntity *child : _children) {
-		uint16 handledEvents = child->findActorToAcceptKeyboardEvents(asciiCode, eventMask, state);
+		uint16 handledEvents = child->findActorToAcceptKeyboardEvents(charCode, eventMask, state);
 		if (handledEvents != 0) {
 			eventMask &= ~handledEvents;
 			result |= handledEvents;
@@ -840,7 +840,7 @@ void StageDirector::handleMouseEvent(const MouseEvent &event) {
 
 void StageDirector::handleKeyboardEvent(const KeyboardEvent &event) {
 	MouseActorState state;
-	uint16 flags = _rootStage->findActorToAcceptKeyboardEvents(event.keyCode, kKeyDownFlag, state);
+	uint16 flags = _rootStage->findActorToAcceptKeyboardEvents(event.getMediaStationCharCode(), kKeyDownFlag, state);
 	if (flags & kKeyDownFlag) {
 		debugC(5, kDebugEvents, "%s: Dispatching to %s from root stage", __func__, state.keyDown->debugName());
 		state.keyDown->keyboardEvent(event);
diff --git a/engines/mediastation/actors/stage.h b/engines/mediastation/actors/stage.h
index 33b3a7ffd5f..0bca9b06cb7 100644
--- a/engines/mediastation/actors/stage.h
+++ b/engines/mediastation/actors/stage.h
@@ -91,7 +91,7 @@ public:
 		MouseActorState &state,
 		bool clipMouseEvents) override;
 	virtual uint16 findActorToAcceptKeyboardEvents(
-		uint16 asciiCode,
+		uint16 charCode,
 		uint16 eventMask,
 		MouseActorState &state) override;
 	virtual void currentMousePosition(Common::Point &point) override;
diff --git a/engines/mediastation/actors/text.cpp b/engines/mediastation/actors/text.cpp
index 4a91b25257a..e93feb70d79 100644
--- a/engines/mediastation/actors/text.cpp
+++ b/engines/mediastation/actors/text.cpp
@@ -19,6 +19,7 @@
  *
  */
 
+#include "mediastation/debugchannels.h"
 #include "mediastation/actors/text.h"
 #include "mediastation/mediastation.h"
 
@@ -60,8 +61,8 @@ void TextActor::readParameter(Chunk &chunk, ActorHeaderSectionType paramType) {
 	case kActorHeaderTextAcceptedCharRangeWithOffset: {
 		uint firstCharCode = chunk.readTypedUint16();
 		uint lastCharCode = chunk.readTypedUint16();
-		uint charCodeOffset = chunk.readTypedUint16();
-		addAcceptedChars(firstCharCode, lastCharCode, charCodeOffset);
+		uint standardizedCharCode = chunk.readTypedUint16();
+		addAcceptedChars(firstCharCode, lastCharCode, standardizedCharCode);
 		break;
 	}
 
@@ -77,7 +78,7 @@ void TextActor::readParameter(Chunk &chunk, ActorHeaderSectionType paramType) {
 		break;
 
 	case kActorHeaderTextCursorIsVisible:
-		_cursorIsVisible = static_cast<bool>(chunk.readTypedByte());
+		_cursorIsVisible = static_cast<bool>(chunk.readTypedUint16());
 		break;
 
 	case kActorHeaderTextConstrainToWidth:
@@ -168,7 +169,7 @@ ScriptValue TextActor::callMethod(BuiltInMethod methodId, Common::Array<ScriptVa
 
 	case kGetLastPressedCharCodeMethod:
 		ARGCOUNTCHECK(0);
-		returnValue.setToFloat(_pressedCharCode);
+		returnValue.setToFloat(_lastCharCode);
 		break;
 
 	case kTextGetCursorPositionMethod:
@@ -243,10 +244,10 @@ ScriptValue TextActor::callMethod(BuiltInMethod methodId, Common::Array<ScriptVa
 
 	case kTextGetTranslatedCharCode: {
 		ARGCOUNTCHECK(1);
-		uint charId = args[0].asParamToken();
-		uint translatedChar = _acceptedChars.getValOrDefault(charId, 0);
-		if (translatedChar != 0) {
-			returnValue.setToFloat(translatedChar);
+		uint charCode = args[0].asParamToken();
+		uint standardizedCharCode = _standardizedChars.getValOrDefault(charCode, 0);
+		if (standardizedCharCode != 0) {
+			returnValue.setToFloat(standardizedCharCode);
 		} else {
 			// Character not found, so return the input as-is.
 			returnValue = args[0];
@@ -256,35 +257,35 @@ ScriptValue TextActor::callMethod(BuiltInMethod methodId, Common::Array<ScriptVa
 
 	case kTextAddAcceptedCharsMethod: {
 		ARGCOUNTMIN(2);
-		uint startCharId = static_cast<uint>(args[0].asFloat());
-		uint endCharId = static_cast<uint>(args[1].asFloat());
-		uint category = 0;
+		uint startCharCode = static_cast<uint>(args[0].asFloat());
+		uint endCharCode = static_cast<uint>(args[1].asFloat());
+		uint firstStandardizedCharCode = 0;
 		if (args.size() >= 3) {
-			category = static_cast<uint>(args[2].asFloat());
+			firstStandardizedCharCode = static_cast<uint>(args[2].asFloat());
 		}
 
-		addAcceptedChars(startCharId, endCharId, category);
+		addAcceptedChars(startCharCode, endCharCode, firstStandardizedCharCode);
 		break;
 	}
 
 	case kTextIsCharacterAcceptedMethod: {
 		ARGCOUNTCHECK(1);
-		uint charId = static_cast<uint>(args[0].asFloat());
-		bool isAccepted = _acceptedChars.contains(charId);
+		uint charCode = static_cast<uint>(args[0].asFloat());
+		bool isAccepted = _standardizedChars.contains(charCode);
 		returnValue.setToBool(isAccepted);
 		break;
 	}
 
 	case kTextEnableDisableCharacterMethod: {
 		ARGCOUNTCHECK(2);
-		uint charId = static_cast<uint>(args[0].asFloat());
+		uint charCode = static_cast<uint>(args[0].asFloat());
 		bool shouldEnable = static_cast<bool>(args[1].asBool());
 		if (shouldEnable) {
 			// Mark character as accepted.
-			addAcceptedChars(charId, charId, 0);
+			addAcceptedChars(charCode, charCode, 0);
 		} else {
 			// No longer mark character as accepted.
-			_acceptedChars.erase(charId);
+			_standardizedChars.erase(charCode);
 		}
 		break;
 	}
@@ -345,22 +346,109 @@ uint16 TextActor::findActorToAcceptKeyboardEvents(uint16 charCode, uint16 eventM
 	uint16 result = 0;
 	if (_loadIsComplete && (eventMask & kKeyDownFlag) && _isEditable) {
 		// If we have accepted character restrictions, check if character is valid.
-		if (!_acceptedChars.empty()) {
-			if (!_acceptedChars.contains(charCode)) {
+		if (!_standardizedChars.empty()) {
+			if (_standardizedChars.contains(charCode)) {
+				debugC(5, kDebugText, "[%s] %s: Accepted char %d", debugName(), __func__, charCode);
+			} else {
+				debugC(5, kDebugText, "[%s] %s: REJECTED char %d", debugName(), __func__, charCode);
 				return 0;
 			}
+		} else {
+			debugC(5, kDebugText, "[%s] %s: Auto-accepted char %d", debugName(), __func__, charCode);
 		}
 
 		state.keyDown = this;
-		result = kNoFlag;
+		result = kKeyDownFlag;
 	}
 
 	return result;
 }
 
 void TextActor::keyboardEvent(const KeyboardEvent &event) {
-	// TODO: Implement this once we have a title that actually uses it.
-	warning("STUB: %s", __func__);
+	uint_least16_t charCode = event.getMediaStationCharCode();
+	uint standardizedCharCode = _standardizedChars.getValOrDefault(charCode, 0);
+	if (standardizedCharCode == 0) {
+		standardizedCharCode = charCode;
+	}
+	debugC(5, kDebugText, "[%s] %s: charCode: %d (standardized: %d)", debugName(), __func__, _lastCharCode, standardizedCharCode);
+	_lastCharCode = standardizedCharCode;
+
+	ScriptValue charCodeValue;
+	charCodeValue.setToFloat(_lastCharCode);
+	ScriptValue defaultCharCodeValue;
+	defaultCharCodeValue.setToFloat(0.0);
+
+	Common::String updatedText = _text;
+	bool isDirty = false;
+
+	if (standardizedCharCode == kBackspaceCharCode) {
+		if (_cursorPosition != 0) {
+			updatedText.deleteChar(_cursorPosition - 1);
+			_cursorPosition--;
+			isDirty = true;
+			_text = updatedText;
+			debugC(5, kDebugText, "[%s] %s: text: %s", debugName(), __func__, updatedText.c_str());
+
+			if (hasScriptResponse(kTextInputEvent, charCodeValue)) {
+				runScriptResponseIfExists(kTextInputEvent, charCodeValue);
+			} else {
+				runScriptResponseIfExists(kTextInputEvent, defaultCharCodeValue);
+			}
+		}
+
+	} else if (standardizedCharCode == kLeftArrowCharCode) {
+		if (_cursorPosition != 0) {
+			_cursorPosition--;
+			if (_cursorIsVisible) {
+				isDirty = true;
+			}
+		}
+
+	} else if (standardizedCharCode == kRightArrowCharCode) {
+		if (_cursorPosition < _text.size()) {
+			_cursorPosition++;
+			if (_cursorIsVisible) {
+				isDirty = true;
+			}
+		}
+
+	} else {
+		// This is likely a normal ASCII character, so try to insert it in the string.
+		if (_text.size() < _maxLength) {
+			if (_overwriteMode && (_cursorPosition < _text.size())) {
+				updatedText.deleteChar(_cursorPosition);
+			}
+
+			updatedText.insertChar(static_cast<char>(standardizedCharCode), _cursorPosition);
+			debugC(5, kDebugText, "[%s] %s: text: %s", debugName(), __func__, updatedText.c_str());
+			if (_constrainToWidth && (_fontActor != nullptr)) {
+				const int16 maxWidth = getBbox().width();
+				const int16 pixelLength = calcPixelLength(updatedText);
+				if (pixelLength > maxWidth) {
+					warning("%s: Input text exceeds max length", __func__);
+					runScriptResponseIfExists(kTextErrorEvent);
+				}
+			}
+
+			_cursorPosition++;
+			isDirty = true;
+			_text = updatedText;
+
+			if (hasScriptResponse(kTextInputEvent, charCodeValue)) {
+				runScriptResponseIfExists(kTextInputEvent, charCodeValue);
+			} else {
+				runScriptResponseIfExists(kTextInputEvent, defaultCharCodeValue);
+			}
+
+		} else {
+			warning("%s: Input text exceeds max length", __func__);
+			runScriptResponseIfExists(kTextErrorEvent);
+		}
+	}
+
+	if (isDirty) {
+		invalidateLocalBounds();
+	}
 }
 
 bool TextActor::hasScriptResponse(EventType eventType, const ScriptValue &arg) const {
@@ -391,18 +479,18 @@ void TextActor::setText() {
 	// Apply character translation if we have any.
 	for (uint positionInString = 0; positionInString < _text.size(); positionInString++) {
 		char currentChar = _text[positionInString];
-		uint translatedChar = _acceptedChars.getValOrDefault(currentChar);
-		if (translatedChar != 0) {
-			_text.setChar(translatedChar, positionInString);
-		}
+		uint standardizedCharCode = _standardizedChars.getValOrDefault(currentChar, currentChar);
+		_text.setChar(standardizedCharCode, positionInString);
 	}
 }
 
-void TextActor::addAcceptedChars(uint firstCharCode, uint lastCharCode, uint charCodeOffset) {
+void TextActor::addAcceptedChars(uint firstCharCode, uint lastCharCode, uint standardizedCharCode) {
 	for (uint charCode = firstCharCode; charCode <= lastCharCode; charCode++) {
-		_acceptedChars.setVal(charCode, charCodeOffset);
-		if (charCodeOffset != 0) {
-			charCodeOffset++;
+		if (!_standardizedChars.contains(charCode)) {
+			_standardizedChars.setVal(charCode, standardizedCharCode);
+		}
+		if (standardizedCharCode != 0) {
+			standardizedCharCode++;
 		}
 	}
 }
@@ -448,7 +536,7 @@ void TextActor::drawCharacter(FontCharacter *glyph, int16 x, int16 y, DisplayCon
 }
 
 void TextActor::drawCursor(int16 x, int16 y, DisplayContext &displayContext) {
-	FontCharacter *cursorChar = _fontActor->lookupCharacter(CURSOR_CHAR_ID);
+	FontCharacter *cursorChar = _fontActor->lookupCharacter(kCursorCharCode);
 	if (cursorChar != nullptr) {
 		drawCharacter(cursorChar, x, y, displayContext);
 	}
@@ -472,7 +560,7 @@ int16 TextActor::calcPixelLength(const Common::String &text) {
 
 	// Add cursor width if we are needing to show the cursor.
 	if (_cursorPosition == _text.size() && _cursorIsVisible) {
-		FontCharacter *cursorChar = _fontActor->lookupCharacter(CURSOR_CHAR_ID);
+		FontCharacter *cursorChar = _fontActor->lookupCharacter(kCursorCharCode);
 		if (cursorChar != nullptr) {
 			totalXOffset += cursorChar->width();
 		}
diff --git a/engines/mediastation/actors/text.h b/engines/mediastation/actors/text.h
index f0654a18909..10123bd32ef 100644
--- a/engines/mediastation/actors/text.h
+++ b/engines/mediastation/actors/text.h
@@ -56,19 +56,19 @@ public:
 	virtual void keyboardEvent(const KeyboardEvent &event) override;
 
 private:
-	static const uint CURSOR_CHAR_ID = 0x104;
 	bool _isEditable = false;
 	Common::String _text;
 	uint _maxLength = 0;
 	FontActor *_fontActor = nullptr;
 	TextJustification _justification = kTextJustificationLeft;
 	TextPosition _position = kTextPositionTop;
-	Common::HashMap<uint, uint> _acceptedChars;
 	uint _cursorPosition = 0;
-	uint _pressedCharCode = 0;
+	uint _lastCharCode = 0;
 	bool _cursorIsVisible = false;
 	bool _constrainToWidth = false;
 	bool _overwriteMode = false;
+	// This lets us transparently convert lowercase to uppercase, for instance.
+	Common::HashMap<uint, uint> _standardizedChars;
 
 	void setText();
 	void addAcceptedChars(uint firstCharCode, uint lastCharCode, uint charCodeOffset = 0);
diff --git a/engines/mediastation/debugchannels.h b/engines/mediastation/debugchannels.h
index bf75c59acda..e8f0ee8a66e 100644
--- a/engines/mediastation/debugchannels.h
+++ b/engines/mediastation/debugchannels.h
@@ -35,6 +35,7 @@ enum DebugChannels {
 	kDebugCamera,
 	kDebugPath,
 	kDebugScan,
+	kDebugText,
 	kDebugScript,
 	kDebugEvents,
 	kDebugLoading,
diff --git a/engines/mediastation/detection.cpp b/engines/mediastation/detection.cpp
index ccfd49d637c..177dec22fa7 100644
--- a/engines/mediastation/detection.cpp
+++ b/engines/mediastation/detection.cpp
@@ -37,6 +37,7 @@ const DebugChannelDef MediaStationMetaEngineDetection::debugFlagList[] = {
 	{ MediaStation::kDebugCamera, "camera", "Camera panning debug level" },
 	{ MediaStation::kDebugPath, "path", "Pathfinding debug level" },
 	{ MediaStation::kDebugScan, "scan", "Scan for unrecognised games" },
+	{ MediaStation::kDebugText, "text", "Text display" },
 	{ MediaStation::kDebugScript, "script", "Enable debug script dump" },
 	{ MediaStation::kDebugEvents, "events", "Events processing" },
 	{ MediaStation::kDebugLoading, "loading", "File loading" },
diff --git a/engines/mediastation/events.cpp b/engines/mediastation/events.cpp
index d5305ebd5ee..b3d88b711e5 100644
--- a/engines/mediastation/events.cpp
+++ b/engines/mediastation/events.cpp
@@ -90,8 +90,40 @@ Common::String MouseEvent::debugString() const {
 }
 
 Common::String KeyboardEvent::debugString() const {
-	return Common::String::format("%s %s (keyCode: %u)",
-		eventClassToStr(eventClass), eventTypeToStr(type), keyCode);
+	return Common::String::format("%s %s (state: { keycode: %u, ascii: %u, flags: 0x%02x })",
+		eventClassToStr(eventClass), eventTypeToStr(type),
+		static_cast<uint>(state.keycode), state.ascii, state.flags);
+}
+
+uint16 KeyboardEvent::getMediaStationCharCode() const {
+	uint16 result = state.ascii;
+	switch (state.keycode) {
+	case Common::KEYCODE_BACKSPACE:
+		result = static_cast<uint16>(kBackspaceCharCode);
+		break;
+
+	case Common::KEYCODE_LEFT:
+		result = static_cast<uint16>(kLeftArrowCharCode);
+		break;
+
+	case Common::KEYCODE_RIGHT:
+		result = static_cast<uint16>(kRightArrowCharCode);
+		break;
+
+	case Common::KEYCODE_UP:
+		result = static_cast<uint16>(kUpArrowCharCode);
+		break;
+
+	case Common::KEYCODE_DOWN:
+		result = static_cast<uint16>(kDownArrowCharCode);
+		break;
+
+	default:
+		// This is explicitly a no-op.
+		break;
+	}
+
+	return result;
 }
 
 void EventLoop::run() {
@@ -208,7 +240,7 @@ void MediaStationEngine::dispatchOneSystemEvent(const Common::Event &event) {
 	}
 
 	case Common::EVENT_KEYDOWN: {
-		KeyboardEvent keyboardEvent(kKeyDownEvent, event.kbd.ascii);
+		KeyboardEvent keyboardEvent(kKeyDownEvent, event.kbd);
 		_stageDirector->handleKeyboardEvent(keyboardEvent);
 		break;
 	}
diff --git a/engines/mediastation/events.h b/engines/mediastation/events.h
index 6db1d4d5fb2..04d2e021111 100644
--- a/engines/mediastation/events.h
+++ b/engines/mediastation/events.h
@@ -131,13 +131,28 @@ struct MouseEvent : public Event {
 	Common::String debugString() const override;
 };
 
+enum SpecialCharCodes {
+	kBackspaceCharCode = 0x08,
+	kLeftArrowCharCode = 0x100,
+	kRightArrowCharCode = 0x101,
+	kUpArrowCharCode = 0x102,
+	kDownArrowCharCode = 0x103,
+	kCursorCharCode = 0x104,
+};
+
 struct KeyboardEvent : public Event {
-	uint16 keyCode = 0;
+	Common::KeyState state;
 
-	KeyboardEvent(EventType eventType_, uint16 keyCode_)
-		: Event(kEventClassKeyboard, eventType_), keyCode(keyCode_) {}
+	KeyboardEvent(EventType eventType_, Common::KeyState state_)
+		: Event(kEventClassKeyboard, eventType_), state(state_) {}
 	Event *clone() const override { return new KeyboardEvent(*this); }
 	Common::String debugString() const override;
+
+	// Scripts and text actors use a 16-bit field for their char codes,
+	// which also encapsulates non-text keys like backspace and arrow keys.
+	// This converts the platform-independent ScummVM keystate into what scripts
+	// want. See MAC_App::doKeyDownEvent in the 68k disassembly.
+	uint16 getMediaStationCharCode() const;
 };
 
 enum PreDisplaySyncState {


Commit: e608521bed3cd769734dd16bcba9a88c9eb4e5b3
    https://github.com/scummvm/scummvm/commit/e608521bed3cd769734dd16bcba9a88c9eb4e5b3
Author: Nathanael Gentry (nathanael.gentrydb8 at gmail.com)
Date: 2026-07-18T19:56:00-04:00

Commit Message:
MEDIASTATION: Implement key events on hotspots

Changed paths:
    engines/mediastation/actor.cpp
    engines/mediastation/actor.h
    engines/mediastation/actors/hotspot.cpp
    engines/mediastation/actors/hotspot.h
    engines/mediastation/actors/text.cpp
    engines/mediastation/actors/text.h


diff --git a/engines/mediastation/actor.cpp b/engines/mediastation/actor.cpp
index d46e7210071..daee8e3f0f2 100644
--- a/engines/mediastation/actor.cpp
+++ b/engines/mediastation/actor.cpp
@@ -214,6 +214,22 @@ ScriptResponse *Actor::findNextTimeScriptResponseAfter(uint32 after) const {
 	return nullptr;
 }
 
+bool Actor::hasScriptResponse(EventType eventType, const ScriptValue &arg) const {
+	const Common::Array<ScriptResponse *> &scriptResponses = _scriptResponses.getValOrDefault(eventType);
+	for (const ScriptResponse *scriptResponse : scriptResponses) {
+		const ScriptValue &argToCheck = scriptResponse->_argumentValue;
+
+		if (arg.getType() != argToCheck.getType()) {
+			continue;
+		}
+
+		if (arg == argToCheck) {
+			return true;
+		}
+	}
+	return false;
+}
+
 void Actor::runScriptResponseIfExists(EventType eventType, const ScriptValue &arg) {
 	const Common::Array<ScriptResponse *> &scriptResponses = _scriptResponses.getValOrDefault(eventType);
 	for (ScriptResponse *scriptResponse : scriptResponses) {
diff --git a/engines/mediastation/actor.h b/engines/mediastation/actor.h
index fe036b5f0fc..de3dfa6bb71 100644
--- a/engines/mediastation/actor.h
+++ b/engines/mediastation/actor.h
@@ -225,6 +225,7 @@ public:
 
 	virtual void onEvent(const ActorEvent &event);
 	ScriptResponse *findNextTimeScriptResponseAfter(uint32 after) const;
+	bool hasScriptResponse(EventType eventType, const ScriptValue &arg) const;
 	void runScriptResponseIfExists(EventType eventType, const ScriptValue &arg);
 	void runScriptResponseIfExists(EventType eventType);
 
diff --git a/engines/mediastation/actors/hotspot.cpp b/engines/mediastation/actors/hotspot.cpp
index 357a009919d..4a856e697a9 100644
--- a/engines/mediastation/actors/hotspot.cpp
+++ b/engines/mediastation/actors/hotspot.cpp
@@ -153,6 +153,26 @@ uint16 HotspotActor::findActorToAcceptMouseEvents(
 	return result;
 }
 
+uint16 HotspotActor::findActorToAcceptKeyboardEvents(uint16 charCode, uint16 eventMask, MouseActorState &state) {
+	if (!_isActive || !_loadIsComplete) {
+		return 0;
+	}
+
+	if (eventMask & kKeyDownFlag) {
+		ScriptValue charCodeValue;
+		charCodeValue.setToFloat(charCode);
+		ScriptValue wildcardValue;
+		wildcardValue.setToFloat(0.0);
+
+		if (hasScriptResponse(kKeyDownEvent, charCodeValue) || hasScriptResponse(kKeyDownEvent, wildcardValue)) {
+			state.keyDown = this;
+			return kKeyDownFlag;
+		}
+	}
+
+	return 0;
+}
+
 void HotspotActor::activate() {
 	if (!_isActive) {
 		_isActive = true;
@@ -221,6 +241,20 @@ void HotspotActor::mouseMovedEvent(const MouseEvent &event) {
 	runScriptResponseIfExists(kMouseMovedEvent);
 }
 
+void HotspotActor::keyboardEvent(const KeyboardEvent &event) {
+	ScriptValue charCodeValue;
+	charCodeValue.setToFloat(event.getMediaStationCharCode());
+
+	if (hasScriptResponse(event.type, charCodeValue)) {
+		runScriptResponseIfExists(event.type, charCodeValue);
+	} else {
+		// Otherwise, try to do the wildcard match.
+		ScriptValue wildcardValue;
+		wildcardValue.setToFloat(0.0);
+		runScriptResponseIfExists(event.type, wildcardValue);
+	}
+}
+
 void HotspotActor::mouseExitedEvent(const MouseEvent &event) {
 	if (!_isActive) {
 		warning("[%s] %s: Inactive", debugName(), __func__);
diff --git a/engines/mediastation/actors/hotspot.h b/engines/mediastation/actors/hotspot.h
index 0ceac8f326d..730bca91cb3 100644
--- a/engines/mediastation/actors/hotspot.h
+++ b/engines/mediastation/actors/hotspot.h
@@ -46,6 +46,10 @@ public:
 		uint16 eventMask,
 		MouseActorState &state,
 		bool clipMouseEvents) override;
+	virtual uint16 findActorToAcceptKeyboardEvents(
+		uint16 charCode,
+		uint16 eventMask,
+		MouseActorState &state) override;
 
 	void activate();
 	void deactivate();
@@ -55,6 +59,7 @@ public:
 	virtual void mouseEnteredEvent(const MouseEvent &event) override;
 	virtual void mouseExitedEvent(const MouseEvent &event) override;
 	virtual void mouseMovedEvent(const MouseEvent &event) override;
+	virtual void keyboardEvent(const KeyboardEvent &event) override;
 
 	uint _cursorResourceId = 0;
 
diff --git a/engines/mediastation/actors/text.cpp b/engines/mediastation/actors/text.cpp
index e93feb70d79..43b18a1831a 100644
--- a/engines/mediastation/actors/text.cpp
+++ b/engines/mediastation/actors/text.cpp
@@ -451,22 +451,6 @@ void TextActor::keyboardEvent(const KeyboardEvent &event) {
 	}
 }
 
-bool TextActor::hasScriptResponse(EventType eventType, const ScriptValue &arg) const {
-	const Common::Array<ScriptResponse *> &scriptResponses = _scriptResponses.getValOrDefault(eventType);
-	for (const ScriptResponse *scriptResponse : scriptResponses) {
-		const ScriptValue &argToCheck = scriptResponse->_argumentValue;
-
-		if (arg.getType() != argToCheck.getType()) {
-			continue;
-		}
-
-		if (arg == argToCheck) {
-			return true;
-		}
-	}
-	return false;
-}
-
 void TextActor::setText() {
 	// Remove double-quotes if they're the first or last characters.
 	if (_text.firstChar() == '"') {
diff --git a/engines/mediastation/actors/text.h b/engines/mediastation/actors/text.h
index 10123bd32ef..d19aad6fc0b 100644
--- a/engines/mediastation/actors/text.h
+++ b/engines/mediastation/actors/text.h
@@ -72,7 +72,6 @@ private:
 
 	void setText();
 	void addAcceptedChars(uint firstCharCode, uint lastCharCode, uint charCodeOffset = 0);
-	bool hasScriptResponse(EventType eventType, const ScriptValue &arg) const;
 
 	int16 calcStartingXPosition();
 	int16 calcBaseline();


Commit: 0591485c99ff1de146c7b0be6a13b969fda8a93f
    https://github.com/scummvm/scummvm/commit/0591485c99ff1de146c7b0be6a13b969fda8a93f
Author: Nathanael Gentry (nathanael.gentrydb8 at gmail.com)
Date: 2026-07-18T19:56:00-04:00

Commit Message:
MEDIASTATION: Implement hardcoded stalking minigame for Lion King

Assisted-by: gpt-5.5 claude-sonnet-4.5

Changed paths:
  A engines/mediastation/minigames/stalkingzazu.cpp
  A engines/mediastation/minigames/stalkingzazu.h
  A engines/mediastation/statemachine.h
    engines/mediastation/actor.cpp
    engines/mediastation/actor.h
    engines/mediastation/actors/sound.h
    engines/mediastation/actors/sprite.h
    engines/mediastation/context.cpp
    engines/mediastation/debugchannels.h
    engines/mediastation/detection.cpp
    engines/mediastation/mediascript/scriptconstants.cpp
    engines/mediastation/mediascript/scriptconstants.h
    engines/mediastation/minigames/dotgame.cpp
    engines/mediastation/module.mk


diff --git a/engines/mediastation/actor.cpp b/engines/mediastation/actor.cpp
index daee8e3f0f2..f0dcb4b9ec4 100644
--- a/engines/mediastation/actor.cpp
+++ b/engines/mediastation/actor.cpp
@@ -52,8 +52,8 @@ const char *actorTypeToStr(ActorType type) {
 		return "Cursor";
 	case kActorTypeSprite:
 		return "Sprite";
-	case kActorTypeLKZazu:
-		return "LKZazu";
+	case kActorTypeStalkingZazu:
+		return "StalkingZazu";
 	case kActorTypeDotGame:
 		return "DotGame";
 	case kActorTypeDocument:
@@ -557,7 +557,7 @@ void SpatialEntity::moveTo(int16 x, int16 y) {
 	debugC(3, kDebugGraphics, "[%s] %s: (%d, %d) -> (%d, %d)", debugName(), __func__,
 		_originalBoundingBox.origin().x, _originalBoundingBox.origin().y, x, y);
 
-	if (dest == _boundingBox.origin()) {
+	if (dest == _originalBoundingBox.origin()) {
 		// We aren't actually moving anywhere.
 		return;
 	}
@@ -583,7 +583,7 @@ void SpatialEntity::moveToCentered(int16 x, int16 y) {
 }
 
 void SpatialEntity::setBounds(const Common::Rect &bounds) {
-	if (_boundingBox == bounds) {
+	if (_originalBoundingBox == bounds) {
 		// We aren't actually moving anywhere.
 		return;
 	}
diff --git a/engines/mediastation/actor.h b/engines/mediastation/actor.h
index de3dfa6bb71..2e276bb96d5 100644
--- a/engines/mediastation/actor.h
+++ b/engines/mediastation/actor.h
@@ -47,7 +47,7 @@ enum ActorType {
 	kActorTypeImage = 0x0007, // IMG
 	kActorTypeHotspot = 0x000b, // HSP
 	kActorTypeSprite = 0x000e, // SPR
-	kActorTypeLKZazu = 0x000f,
+	kActorTypeStalkingZazu = 0x000f,
 	kActorTypeDotGame = 0x0010,
 	kActorTypeDocument = 0x0011,
 	kActorTypeDiskImage = 0x001d,
@@ -154,6 +154,19 @@ enum ActorHeaderSectionType {
 	kActorHeaderDotGameSpeed = 0x0517,
 	kActorHeaderDotGameLineThickness = 0x0518,
 	kActorHeaderDotGameColor = 0x0519,
+
+	// STALKING ZAZU FIELDS.
+	kActorHeaderStalkingZazuSpriteId = 0x03ec,
+	kActorHeaderStalkingZazuDirections = 0x03ed,
+	kActorHeaderStalkingZazuField = 0x03ee,
+	kActorHeaderStalkingZazuObstacleActorId = 0x03ef,
+	kActorHeaderStalkingZazuUnkX = 0x03f0,
+	kActorHeaderStalkingZazuUnkY = 0x03f1,
+	kActorHeaderStalkingZazuUnkSound1 = 0x03f2,
+	kActorHeaderStalkingZazuUnkSound2 = 0x03f3,
+	kActorHeaderStalkingZazuAudioEnabled = 0x03f4,
+	kActorHeaderStalkingZazuObstaclesToShow = 0x03f5,
+	kActorHeaderStalkingZazuUnkPoint = 0x03f6,
 };
 
 enum CylindricalWrapMode : int;
@@ -282,6 +295,8 @@ public:
 	virtual Common::Rect getBbox() const { return _boundingBox; }
 	int zIndex() const { return _zIndex; }
 	void moveTo(int16 x, int16 y);
+	void moveToCentered(int16 x, int16 y);
+	void setZIndex(int zIndex);
 
 	virtual void currentMousePosition(Common::Point &point);
 	virtual void invalidateMouse();
@@ -325,9 +340,7 @@ protected:
 	bool _hasTransparency = false;
 	StageActor *_parentStage = nullptr;
 
-	void moveToCentered(int16 x, int16 y);
 	void setBounds(const Common::Rect &bounds);
-	void setZIndex(int zIndex);
 	virtual void setMousePosition(int16 x, int16 y);
 
 	virtual void setDissolveFactor(double dissolveFactor);
diff --git a/engines/mediastation/actors/sound.h b/engines/mediastation/actors/sound.h
index a53b7b84f1c..7d1a5d604b8 100644
--- a/engines/mediastation/actors/sound.h
+++ b/engines/mediastation/actors/sound.h
@@ -44,6 +44,11 @@ public:
 	virtual void timerEvent(const TimerEvent &event) override;
 	virtual void soundPlayStateChanged(SoundPlayState state, SoundStopReason why) override;
 
+	void start();
+	void stop();
+	void pause();
+	void resume(bool restart);
+
 private:
 	ImtStreamFeed *_streamFeed = nullptr;
 	bool _isLoadedFromChunk = false;
@@ -51,11 +56,6 @@ private:
 	SoundPlayState _playState = kSoundPlayStateStopped;
 	AudioSequence _sequence;
 
-	void start();
-	void stop();
-	void pause();
-	void resume(bool restart);
-
 	void openStream();
 };
 
diff --git a/engines/mediastation/actors/sprite.h b/engines/mediastation/actors/sprite.h
index d4f6cfccec8..406ca085bec 100644
--- a/engines/mediastation/actors/sprite.h
+++ b/engines/mediastation/actors/sprite.h
@@ -79,6 +79,10 @@ public:
 	virtual void onEvent(const ActorEvent &event) override;
 	virtual void timerEvent(const TimerEvent &event) override;
 
+	bool activateNextFrame();
+	bool activatePreviousFrame();
+	void setCurrentClip(uint clipId);
+
 private:
 	const uint DEFAULT_FORWARD_CLIP_ID = 0x4B0;
 	const uint DEFAULT_BACKWARD_CLIP_ID = 0x4B1;
@@ -96,10 +100,6 @@ private:
 
 	void play();
 	void stop();
-	void setCurrentClip(uint clipId);
-
-	bool activateNextFrame();
-	bool activatePreviousFrame();
 
 	void dirtyIfVisible();
 	void setCurrentFrameToInitial();
diff --git a/engines/mediastation/context.cpp b/engines/mediastation/context.cpp
index 5fbd3532440..075f932f3d9 100644
--- a/engines/mediastation/context.cpp
+++ b/engines/mediastation/context.cpp
@@ -44,6 +44,7 @@
 #include "mediastation/actors/font.h"
 #include "mediastation/actors/text.h"
 #include "mediastation/minigames/dotgame.h"
+#include "mediastation/minigames/stalkingzazu.h"
 
 namespace MediaStation {
 
@@ -135,6 +136,10 @@ void ImtGod::readCreateActorData(Chunk &chunk) {
 		actor = new SpriteMovieActor();
 		break;
 
+	case kActorTypeStalkingZazu:
+		actor = new StalkingZazuActor();
+		break;
+
 	case kActorTypeCanvas:
 		actor = new CanvasActor();
 		break;
diff --git a/engines/mediastation/debugchannels.h b/engines/mediastation/debugchannels.h
index e8f0ee8a66e..25ef803ea15 100644
--- a/engines/mediastation/debugchannels.h
+++ b/engines/mediastation/debugchannels.h
@@ -40,6 +40,7 @@ enum DebugChannels {
 	kDebugEvents,
 	kDebugLoading,
 	kDebugSpriteMovie,
+	kDebugMinigame,
 };
 
 } // End of namespace MediaStation
diff --git a/engines/mediastation/detection.cpp b/engines/mediastation/detection.cpp
index 177dec22fa7..0c3cc504dca 100644
--- a/engines/mediastation/detection.cpp
+++ b/engines/mediastation/detection.cpp
@@ -42,6 +42,7 @@ const DebugChannelDef MediaStationMetaEngineDetection::debugFlagList[] = {
 	{ MediaStation::kDebugEvents, "events", "Events processing" },
 	{ MediaStation::kDebugLoading, "loading", "File loading" },
 	{ MediaStation::kDebugSpriteMovie, "spritemovie", "Sprite movie debug level" },
+	{ MediaStation::kDebugMinigame, "minigame", "Hardcoded minigames" },
 	DEBUG_CHANNEL_END
 };
 
diff --git a/engines/mediastation/mediascript/scriptconstants.cpp b/engines/mediastation/mediascript/scriptconstants.cpp
index a0a89b54d6d..de86291f59d 100644
--- a/engines/mediastation/mediascript/scriptconstants.cpp
+++ b/engines/mediastation/mediascript/scriptconstants.cpp
@@ -476,14 +476,24 @@ const char *builtInMethodToStr(BuiltInMethod method) {
 		return "StopLoad";
 	case kIsRectInMemoryMethod:
 		return "IsRectInMemory";
-	case kDotGameResetMethod:
-		return "DotGameReset";
-	case kDotGameShowMethod:
-		return "DotGameShow";
-	case kDotGameHideMethod:
-		return "DotGameHide";
+	case kMinigameResetMethod:
+		return "MinigameReset";
+	case kMinigameActivateMethod:
+		return "MinigameActivate";
+	case kMinigameDeactivateMethod:
+		return "MinigameDeactivate";
 	case kDotGameHitMethod:
 		return "DotGameHit";
+	case kStalkingStartZazuLookingMethod:
+		return "StalkingSetLookOn";
+	case kStalkingStopZazuLookingMethod:
+		return "StalkingSetLookOff";
+	case kStalkingGetZazuLookDirectionMethod:
+		return "StalkingGetLookDirection";
+	case kStalkingEnableAudio:
+		return "StalkingEnableAudio";
+	case kStalkingDisableAudio:
+		return "StalkingDisableAudio";
 	default:
 		return "UNKNOWN";
 	}
@@ -537,8 +547,12 @@ const char *eventTypeToStr(EventType type) {
 		return "MovieFailure";
 	case kSpriteMovieEndEvent:
 		return "SpriteMovieEnd";
-	case kDotGameCompleteEvent:
-		return "DotGameComplete";
+	case kMinigameSuccessEvent:
+		return "MinigameSuccess";
+	case kMinigameDefeatEvent:
+		return "MinigameDefeat";
+	case kMinigameCrouchedEvent:
+		return "MinigameCrouched";
 	case kScreenExitEvent:
 		return "ScreenExit";
 	case kPathStepEvent:
diff --git a/engines/mediastation/mediascript/scriptconstants.h b/engines/mediastation/mediascript/scriptconstants.h
index 07f433dbe2a..8e1e9c3e7e4 100644
--- a/engines/mediastation/mediascript/scriptconstants.h
+++ b/engines/mediastation/mediascript/scriptconstants.h
@@ -309,11 +309,18 @@ enum BuiltInMethod {
 	kStopLoadMethod = 0x168,
 	kIsRectInMemoryMethod = 0x16A,
 
-	// DOT GAME ACTOR METHODS.
-	kDotGameResetMethod = 0xE2,
-	kDotGameShowMethod = 0xE3,
-	kDotGameHideMethod = 0xE4,
+	// MINIGAME ACTOR METHODS.
+	kMinigameResetMethod = 0xE2,
+	kMinigameActivateMethod = 0xE3,
+	kMinigameDeactivateMethod = 0xE4,
 	kDotGameHitMethod = 0xE5,
+
+	// STALKING ACTOR METHODS.
+	kStalkingStartZazuLookingMethod = 0xDF,
+	kStalkingStopZazuLookingMethod = 0xE0,
+	kStalkingGetZazuLookDirectionMethod = 0xE1,
+	kStalkingEnableAudio = 0xE7,
+	kStalkingDisableAudio = 0xE8,
 };
 const char *builtInMethodToStr(BuiltInMethod method);
 
@@ -341,7 +348,9 @@ enum EventType {
 	kMovieAbortEvent = 0x15,
 	kMovieFailureEvent = 0x16,
 	kSpriteMovieEndEvent = 0x17,
-	kDotGameCompleteEvent = 0x18,
+	kMinigameSuccessEvent = 0x18,
+	kMinigameDefeatEvent = 0x19,
+	kMinigameCrouchedEvent = 0x1A,
 	kScreenExitEvent = 0x1B,
 	kPathStepEvent = 0x1C,
 	kSoundStoppedEvent = 0x1D,
diff --git a/engines/mediastation/minigames/dotgame.cpp b/engines/mediastation/minigames/dotgame.cpp
index 98aaa285491..bd2470381e0 100644
--- a/engines/mediastation/minigames/dotgame.cpp
+++ b/engines/mediastation/minigames/dotgame.cpp
@@ -76,14 +76,14 @@ void DotGameActor::readParameter(Chunk &chunk, ActorHeaderSectionType paramType)
 ScriptValue DotGameActor::callMethod(BuiltInMethod methodId, Common::Array<ScriptValue> &args) {
 	ScriptValue returnValue;
 	switch (methodId) {
-	case kDotGameResetMethod: {
+	case kMinigameResetMethod: {
 		ARGCOUNTCHECK(1);
 		int16 targetDot = static_cast<int16>(args[0].asFloat());
 		doReset(targetDot);
 		break;
 	}
 
-	case kDotGameShowMethod:
+	case kMinigameActivateMethod:
 		ARGCOUNTCHECK(0);
 		if (!_isVisible) {
 			_isVisible = true;
@@ -93,7 +93,7 @@ ScriptValue DotGameActor::callMethod(BuiltInMethod methodId, Common::Array<Scrip
 		}
 		break;
 
-	case kDotGameHideMethod:
+	case kMinigameDeactivateMethod:
 		ARGCOUNTCHECK(0);
 		if (_isVisible) {
 			_isVisible = false;
@@ -259,10 +259,10 @@ void DotGameActor::doHit() {
 
 	bool allDotsCompleted = (_totalDots - 1 == _currentDotIndex);
 	if (allDotsCompleted) {
-		callMethod(kDotGameHideMethod, emptyArgs);
+		callMethod(kMinigameDeactivateMethod, emptyArgs);
 		_currentDotIndex = 0;
 		_animationProgress = 100;
-		runScriptResponseIfExists(kDotGameCompleteEvent);
+		runScriptResponseIfExists(kMinigameSuccessEvent);
 	} else {
 		updateHelpers();
 		activateHelpers();
@@ -328,7 +328,7 @@ void DotGameActor::loadIsComplete() {
 	if (_isVisible) {
 		_isVisible = false;
 		Common::Array<ScriptValue> emptyArgs;
-		callMethod(kDotGameHideMethod, emptyArgs);
+		callMethod(kMinigameDeactivateMethod, emptyArgs);
 	}
 
 	SpatialEntity::loadIsComplete();
diff --git a/engines/mediastation/minigames/stalkingzazu.cpp b/engines/mediastation/minigames/stalkingzazu.cpp
new file mode 100644
index 00000000000..761f78a97f3
--- /dev/null
+++ b/engines/mediastation/minigames/stalkingzazu.cpp
@@ -0,0 +1,1278 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "math/utils.h"
+#include "graphics/paletteman.h"
+
+#include "mediastation/minigames/stalkingzazu.h"
+#include "mediastation/actors/sprite.h"
+#include "mediastation/actors/sound.h"
+#include "mediastation/datafile.h"
+#include "mediastation/debugchannels.h"
+#include "mediastation/mediastation.h"
+
+namespace MediaStation {
+
+namespace StalkingZazuMinigame {
+
+Direction::Direction(uint directionToken) {
+	switch(directionToken) {
+	case 0x44c:
+		_value = Direction::kWest;
+		break;
+
+	case 0x44d:
+		_value = Direction::kSouthWest;
+		break;
+
+	case 0x44e:
+		_value = Direction::kSouth;
+		break;
+
+	case 0x44f:
+		_value = Direction::kSouthEast;
+		break;
+
+	case 0x450:
+		_value = Direction::kEast;
+		break;
+
+	case 0x451:
+		_value = Direction::kNorthEast;
+		break;
+
+	case 0x452:
+		_value = Direction::kNorth;
+		break;
+
+	case 0x453:
+		_value = Direction::kNorthWest;
+		break;
+
+	default:
+		error("%s: Got bad direction token %d", __func__, directionToken);
+	}
+}
+
+StalkingZazuMinigame::Direction StalkingZazuMinigame::Direction::rotate(uint steps) const {
+	uint zeroBased = (_value - 1 + steps) % Direction::TOTAL_DIRECTIONS;
+	return static_cast<Value>(zeroBased + 1);
+}
+
+uint StalkingZazuMinigame::StalkClips::indexOf(Direction direction, bool isSimbaCrouching) {
+	uint index = static_cast<uint>(direction);
+	if (isSimbaCrouching) {
+		// The clips are read in such that, for one direction, the crouched clip is always
+		// this far away from the uncrouched clip.
+		index += 9;
+	}
+	return index;
+}
+
+CellCoord CellCoord::getAdjacentCell(const Direction &direction) const {
+	// Compute the neighbour purely arithmetically. Whether the result lands on
+	// the grid is the field's concern, so this must be validated before use.
+	CellCoord adjacentCell(x, y);
+
+	switch (direction) {
+	case Direction::kEast:
+		adjacentCell.x = adjacentCell.x + 1;
+		break;
+
+	case Direction::kNorthEast:
+		adjacentCell.y = adjacentCell.y - 1;
+		adjacentCell.x = adjacentCell.x + 1;
+		break;
+
+	case Direction::kNorth:
+		adjacentCell.y = adjacentCell.y - 1;
+		break;
+
+	case Direction::kNorthWest:
+		adjacentCell.y = adjacentCell.y - 1;
+		adjacentCell.x = adjacentCell.x - 1;
+		break;
+
+	case Direction::kWest:
+		adjacentCell.x = adjacentCell.x - 1;
+		break;
+
+	case Direction::kSouthWest:
+		adjacentCell.x = adjacentCell.x - 1;
+		adjacentCell.y = adjacentCell.y + 1;
+		break;
+
+	case Direction::kSouth:
+		adjacentCell.y = adjacentCell.y + 1;
+		break;
+
+	case Direction::kSouthEast:
+		adjacentCell.y = adjacentCell.y + 1;
+		adjacentCell.x = adjacentCell.x + 1;
+		break;
+
+	default:
+		error("%s: Got bad direction token %d", __func__, static_cast<uint>(direction));
+	}
+
+	return adjacentCell;
+}
+
+StalkingField::StalkingField(int rowCount, int columnCount, const Common::Point &graphicalSize) {
+	_graphicalSize = graphicalSize;
+	_columns = columnCount;
+	_rows = rowCount;
+	_values.resize(rowCount * columnCount);
+}
+
+uint16 &StalkingField::at(int row, int column) {
+	assert(row < _rows);
+	assert(column < _columns);
+	return _values[(row * _columns) + column];
+}
+
+const uint16 &StalkingField::at(int row, int column) const {
+	assert(row < _rows);
+	assert(column < _columns);
+	return _values[(row * _columns) + column];
+}
+
+uint16 &StalkingField::at(const CellCoord &coord) {
+	assert(coord.y >= 0);
+	assert(coord.x >= 0);
+	return at(coord.y, coord.x);
+}
+
+const uint16 &StalkingField::at(const CellCoord &coord) const {
+	assert(coord.y >= 0);
+	assert(coord.x >= 0);
+	return at(coord.y, coord.x);
+}
+
+bool StalkingField::isInBounds(int row, int column) const {
+	return (row >= 0) && (column >= 0) &&
+		(row < _rows) && (column < _columns);
+}
+
+bool StalkingField::isInBounds(const CellCoord &coord) const {
+	return isInBounds(coord.y, coord.x);
+}
+
+void StalkingField::clear() {
+	Common::fill(_values.begin(), _values.end(), 0);
+}
+
+Common::Point StalkingField::bottomCenterPositionInFieldCoordinates() {
+	Common::Point bottomCenterPosition = Common::Point(
+		_graphicalSize.x / 2.0,
+		_graphicalSize.y - (cellSize().y / 2.0));
+
+	debugC(5, kDebugMinigame, "%s: (%d, %d) [graphicalSize: (%d, %d); cellSize: (%d, %d)]",
+		__func__, bottomCenterPosition.x, bottomCenterPosition.y,
+		_graphicalSize.x, _graphicalSize.y,
+		cellSize().x, cellSize().y);
+
+	return bottomCenterPosition;
+}
+
+CellCoord StalkingField::cellAtPoint(const Common::Point &pointInGraphicalCoordinates) {
+	// Get the logical cell at the given graphical point.
+	if ((_columns == 0) || (_rows == 0) || (_graphicalSize.x == 0) || (_graphicalSize.y == 0)) {
+		error("%s: Attempted to get cell in empty field", __func__);
+	}
+
+	const int16 column = MIN<int16>(pointInGraphicalCoordinates.x * _columns / _graphicalSize.x, _columns - 1);
+	const int16 row = MIN<int16>(pointInGraphicalCoordinates.y * _rows / _graphicalSize.y, _rows - 1);
+	return CellCoord(column, row);
+}
+
+Common::Point StalkingField::cellSize() {
+	// Get the size of each cell in graphical units.
+	return Common::Point(
+		static_cast<int16>(_graphicalSize.x  / (double)_columns),
+		static_cast<int16>(_graphicalSize.y / (double)_rows));
+}
+
+Common::Point StalkingField::centerOfCell(const CellCoord &coord) {
+	// Get the center of each cell in graphical units.
+	Common::Point size = cellSize();
+	return Common::Point(
+		static_cast<int16>(coord.x * size.x  + size.x  / 2.0),
+		static_cast<int16>(coord.y * size.y + size.y / 2.0));
+}
+
+bool StalkingField::obstacleCanBePlaced(const CellCoord &coord) {
+	Direction direction(Direction::kNone);
+	Common::Array<CellCoord> scratch;
+	return obstacleCanBePlaced(coord, direction, scratch);
+}
+
+bool StalkingField::obstacleCanBePlaced(CellCoord currentPos, Direction direction, Common::Array<CellCoord> &visitedCells) {
+	// Don't allow placing an obstacle along any border.
+	bool canPlace = true;
+	visitedCells.push_back(currentPos);
+	if ((currentPos.y == 0) || (currentPos.x == 0) || \
+	 		(currentPos.y == _rows - 1) || \
+			(currentPos.x == _columns - 1)) {
+		return false;
+	}
+
+	// Don't allow placing an obstacle directly above or below an existing one.
+	if ((direction == Direction::kSouth) || (direction == Direction::kNorth)) {
+		return false;
+	}
+
+	Direction startDirection;
+	Direction stopDirection;
+	if (direction == Direction::kNone) {
+		startDirection = Direction::kEast;
+		stopDirection = Direction::kEast;
+	} else {
+		startDirection = direction.counterClockwise();
+		stopDirection = direction;
+	}
+
+	Direction currentDirection = startDirection;
+	Direction rotatedDirection;
+	do {
+		CellCoord adjacentCell = currentPos.getAdjacentCell(currentDirection);
+		if (isInBounds(adjacentCell)) {
+			if (!isEmpty(adjacentCell)) {
+				bool adjacentCellWasVisited = false;
+				// TODO: Can we use a hashmap here?
+				for (const CellCoord &visitedCell : visitedCells) {
+					if (visitedCell == adjacentCell) {
+						adjacentCellWasVisited = true;
+						break;
+					}
+				}
+
+				if (!adjacentCellWasVisited) {
+					const Direction reverseDirection = currentDirection.opposite();
+					canPlace = obstacleCanBePlaced(adjacentCell, reverseDirection, visitedCells);
+				}
+			}
+		}
+
+		rotatedDirection = currentDirection.counterClockwise();
+		currentDirection = rotatedDirection;
+		if (rotatedDirection == stopDirection) {
+			break;
+		}
+	} while (canPlace);
+	return canPlace;
+}
+
+
+void StalkingField::placeObstacles(const Common::Array<uint> &obstacleActorIds) {
+	// Randomly partition the obstacles across the playable rows.
+	// Each partition boundary marks the end of the current row's allotment of obstacles.
+	// Each row receives a consecutive range of obstacles while randomly varying
+	// how many obstacles each row contains.
+	Common::Array<uint16> partitionBoundaryIndices = calcPartitionBoundaries(_rows - 2, obstacleActorIds.size());
+	uint obstacleIndex = 0;
+	uint row = 1;
+
+	for (uint16 partitionBoundaryIndex : partitionBoundaryIndices) {
+		while (obstacleIndex < partitionBoundaryIndex) {
+			bool obstacleAdded = addObstacleToRow(static_cast<uint16>(obstacleActorIds[obstacleIndex]), row);
+			if (!obstacleAdded) {
+				// Give up on this row.
+				break;
+			}
+
+			obstacleIndex++;
+		}
+		row++;
+	}
+}
+
+Common::Array<uint16> StalkingField::calcPartitionBoundaries(uint totalRows, uint totalObstacles) {
+	// Generate random cumulative partition boundaries for an ordered list.
+	Common::Array<uint16> boundaryIndices;
+	for (uint i = 0; i < totalRows; i++) {
+		uint16 breakPoint = static_cast<uint16>(g_engine->_randomSource.getRandomNumber(totalObstacles));
+		boundaryIndices.push_back(breakPoint);
+	}
+
+	Common::sort(boundaryIndices.begin(), boundaryIndices.end());
+	return boundaryIndices;
+}
+
+
+bool StalkingField::addObstacleToRow(uint16 obstacleActorId, uint16 row) {
+	Common::Array<CellCoord> availableCellsOnRow = calcAvailableCellsOnRow(row);
+
+	bool obstacleAdded = false;
+	while (availableCellsOnRow.size() > 0) {
+		uint16 index = g_engine->_randomSource.getRandomNumber(availableCellsOnRow.size() - 1);
+		CellCoord &coord = availableCellsOnRow[index];
+		if (obstacleCanBePlaced(coord)) {
+			at(coord) = obstacleActorId;
+			obstacleAdded = true;
+			break;
+		} else {
+			availableCellsOnRow.remove_at(index);
+		}
+	}
+
+	return obstacleAdded;
+}
+
+Common::Array<CellCoord> StalkingField::calcAvailableCellsOnRow(uint row) {
+	Common::Array<CellCoord> cells;
+	for (int column = 0; column < _columns; ++column) {
+		CellCoord cellCoord(column, row);
+		if (isEmpty(cellCoord)) {
+			cells.push_back(cellCoord);
+		}
+	}
+	return cells;
+}
+
+void GameStateMachine::executeNextState(GameStateEvent event) {
+	switch (event) {
+	case kStalkingZazuResetStateEvent:
+		switch (_currentState) {
+		case kStalkingZazuStateStart:
+		case kStalkingZazuStateCrouched:
+		case kStalkingZazuStateCanMove:
+		case kStalkingZazuStateProcessMove:
+		case kStalkingZazuStateDefeat:
+		case kStalkingZazuStateSuccess:
+			_currentState = kStalkingZazuStateReset;
+			_actor->stalkingState_1_reset();
+			break;
+
+		default:
+			warnOnInvalidTransition(event);
+		}
+		break;
+
+	case kStalkingZazuCrouchStateEvent:
+		switch (_currentState) {
+		case kStalkingZazuStateReset:
+		case kStalkingZazuStateCanMove:
+		case kStalkingZazuStateProcessMove:
+			_currentState = kStalkingZazuStateCrouched;
+			_actor->stalkingState_2_crouched();
+			break;
+
+		default:
+			warnOnInvalidTransition(event);
+		}
+		break;
+
+	case kStalkingZazuUncrouchStateEvent:
+		switch (_currentState) {
+		case kStalkingZazuStateCrouched:
+			_currentState = kStalkingZazuStateCanMove;
+			_actor->stalkingState_3_canMove();
+			break;
+
+		default:
+			warnOnInvalidTransition(event);
+		}
+		break;
+
+	case kStalkingZazuMoveStateEvent:
+		switch (_currentState) {
+		case kStalkingZazuStateCanMove:
+		case kStalkingZazuStateProcessMove:
+			_currentState = kStalkingZazuStateProcessMove;
+			_actor->stalkingState_4_processMove();
+			break;
+
+		default:
+			warnOnInvalidTransition(event);
+		}
+		break;
+
+	case kStalkingZazuLoseStateEvent:
+		switch (_currentState) {
+		case kStalkingZazuStateCanMove:
+		case kStalkingZazuStateProcessMove:
+			_currentState = kStalkingZazuStateDefeat;
+			_actor->stalkingState_5_defeat();
+			break;
+
+		default:
+			warnOnInvalidTransition(event);
+		}
+		break;
+
+	case kStalkingZazuWinStateEvent:
+		switch (_currentState) {
+		case kStalkingZazuStateCanMove:
+		case kStalkingZazuStateProcessMove:
+			_currentState = kStalkingZazuStateSuccess;
+			_actor->stalkingState_6_success();
+			break;
+
+		default:
+			warnOnInvalidTransition(event);
+		}
+		break;
+
+	default:
+		warnOnInvalidTransition(event);
+	}
+}
+
+void TimerStateMachine::executeNextState(TimerEvent eventType) {
+	switch (eventType) {
+	case kStalkingZazuActivateTimerEvent:
+		switch (_currentState) {
+		case kStalkingZazuExpiredInactiveTimerState:
+			_currentState = kStalkingZazuExpiredActiveTimerState;
+			// This state doesn't have a callback.
+			break;
+
+		case kStalkingZazuQueuedInactiveTimerState:
+			_currentState = kStalkingZazuQueuedTimerState;
+			_actor->timerState_1_queued();
+			break;
+
+		default:
+			warnOnInvalidTransition(eventType);
+		}
+		break;
+
+	case kStalkingZazuInactivateTimerEvent:
+		switch (_currentState) {
+		case kStalkingZazuExpiredTimerState:
+		case kStalkingZazuExpiredActiveTimerState:
+			_currentState = kStalkingZazuExpiredInactiveTimerState;
+			// This state doesn't have a callback.
+			break;
+
+		case kStalkingZazuQueuedTimerState:
+			_currentState = kStalkingZazuQueuedInactiveTimerState;
+			// This state doesn't have a callback.
+			break;
+
+		default:
+			warnOnInvalidTransition(eventType);
+		}
+		break;
+
+	case kStalkingZazuExpireTimerEvent:
+		switch (_currentState) {
+		case kStalkingZazuQueuedTimerState:
+			_currentState = kStalkingZazuExpiredTimerState;
+			_actor->timerState_0_expired();
+			break;
+
+		default:
+			warnOnInvalidTransition(eventType);
+		}
+		break;
+
+	case kStalkingZazuQueueTimerEvent:
+		switch (_currentState) {
+		case kStalkingZazuExpiredTimerState:
+		case kStalkingZazuExpiredActiveTimerState:
+			_currentState = kStalkingZazuQueuedTimerState;
+			_actor->timerState_1_queued();
+			break;
+
+		case kStalkingZazuExpiredInactiveTimerState:
+			_currentState = kStalkingZazuQueuedInactiveTimerState;
+			// This state doesn't have a callback.
+			break;
+
+		default:
+			warnOnInvalidTransition(eventType);
+		}
+		break;
+
+	default:
+		warnOnInvalidTransition(eventType);
+	}
+}
+
+} // End of namespace StalkingZazuMinigame
+
+using namespace StalkingZazuMinigame;
+void StalkingZazuActor::readParameter(Chunk &chunk, ActorHeaderSectionType paramType) {
+	switch (paramType) {
+	case kActorHeaderCursorResourceId:
+		_cursorResourceId = chunk.readTypedUint16();
+		break;
+
+	case kActorHeaderFrameRate: {
+		double timerFiresPerSecond = chunk.readTypedDouble();
+		_timerDurationInSeconds = 1.0 / timerFiresPerSecond;
+		break;
+	}
+
+	case kActorHeaderEditable:
+		_isActive = static_cast<bool>(chunk.readTypedByte());
+		break;
+
+	case kActorHeaderStalkingZazuSpriteId:
+		_simbaActorId = chunk.readTypedUint16();
+		break;
+
+	case kActorHeaderStalkingZazuDirections:
+		_stalkClips.resize(StalkClips::SIZE);
+		for (uint i = 0; i < 5; i++) {
+			uint directionToken = chunk.readTypedUint16();
+			Direction direction(directionToken);
+
+			uint nonCrouchedClipId = chunk.readTypedUint16();
+			uint nonCrouchedClipIndex = _stalkClips.indexOf(direction, false);
+			_stalkClips[nonCrouchedClipIndex] = nonCrouchedClipId;
+
+			uint crouchedClipId = chunk.readTypedUint16();
+			uint crouchedClipIndex = _stalkClips.indexOf(direction, true);
+			_stalkClips[crouchedClipIndex] = crouchedClipId;
+		}
+		break;
+
+	case kActorHeaderStalkingZazuField: {
+		uint rowCount = chunk.readTypedUint16();
+		uint columnCount = chunk.readTypedUint16();
+		Common::Point graphicalSize = Common::Point(getBbox().width(), getBbox().height());
+		_stalkingField = StalkingField(rowCount, columnCount, graphicalSize);
+		_targetPosInFieldCoordinates = Common::Point(graphicalSize.x / 2, 0);
+		break;
+	}
+
+	case kActorHeaderStalkingZazuObstacleActorId: {
+		uint obstacleActorId = chunk.readTypedUint16();
+		_obstacleActorIds.push_back(obstacleActorId);
+		break;
+	}
+
+	case kActorHeaderStalkingZazuUnkX:
+		_scalingFactor = chunk.readTypedGraphicUnit();
+		break;
+
+	case kActorHeaderStalkingZazuUnkY:
+		_successRadius = chunk.readTypedGraphicUnit();
+		break;
+
+	case kActorHeaderStalkingZazuUnkSound1:
+		_soundEffectId1 = chunk.readTypedUint16();
+		break;
+
+	case kActorHeaderStalkingZazuUnkSound2:
+		_soundEffectId2 = chunk.readTypedUint16();
+		break;
+
+	case kActorHeaderStalkingZazuAudioEnabled:
+		_soundEffectsEnabled = static_cast<bool>(chunk.readTypedByte());
+		break;
+
+	case kActorHeaderStalkingZazuObstaclesToShow:
+		_obstaclesToShow = chunk.readTypedSint16();
+		break;
+
+	case kActorHeaderStalkingZazuUnkPoint:
+		// This is read into offset 0xDE in the original
+		// but doesn't seem to ever be accessed, so we will throw it away.
+		chunk.readTypedPoint();
+		break;
+
+	default:
+		SpatialEntity::readParameter(chunk, paramType);
+	}
+}
+
+ScriptValue StalkingZazuActor::callMethod(BuiltInMethod methodId, Common::Array<ScriptValue> &args) {
+	ScriptValue returnValue;
+	switch (methodId) {
+	case kStalkingStartZazuLookingMethod:
+		ARGCOUNTCHECK(0);
+		setZazuLook(true);
+		break;
+
+	case kStalkingStopZazuLookingMethod:
+		ARGCOUNTCHECK(0);
+		setZazuLook(false);
+		break;
+
+	case kStalkingGetZazuLookDirectionMethod: {
+		ARGCOUNTCHECK(0);
+		switch (directionZazuWillLook()) {
+		case Direction::kEast:
+			returnValue.setToParamToken(0x450);
+			break;
+
+		case Direction::kWest:
+			returnValue.setToParamToken(0x44c);
+			break;
+
+		default:
+			returnValue.setToParamToken(0x450);
+		}
+		break;
+	}
+
+	case kMinigameResetMethod:
+		ARGCOUNTCHECK(1);
+		_obstaclesToShow = static_cast<int>(args[0].asFloat());
+		generateStalkingEvent(kStalkingZazuResetStateEvent);
+		_gameMachine.runIfNotNested();
+		break;
+
+	case kMinigameActivateMethod:
+		ARGCOUNTCHECK(0);
+		setActive(true);
+		break;
+
+	case kMinigameDeactivateMethod:
+		ARGCOUNTCHECK(0);
+		setActive(false);
+		break;
+
+	case kStalkingEnableAudio:
+		ARGCOUNTCHECK(0);
+		setAudio(true);
+		break;
+
+	case kStalkingDisableAudio:
+		ARGCOUNTCHECK(0);
+		setAudio(false);
+		break;
+
+	default:
+		returnValue = SpatialEntity::callMethod(methodId, args);
+	}
+
+	return returnValue;
+}
+
+bool StalkingZazuActor::isVisible() const {
+	// This actor doesn't actually draw anything by itself. It controls other actors
+	// to show the user the game. However, drawing the stalking grid and such is extremely
+	// useful for debugging. But we only want to draw this grid if debugging is enabled.
+	return debugChannelSet(4, kDebugMinigame);
+}
+
+void StalkingZazuActor::draw(DisplayContext &displayContext) {
+	Graphics::ManagedSurface *targetSurface = displayContext._destImage;
+	if (targetSurface == nullptr) {
+		warning("[%s] %s: No target surface to draw", debugName(), __func__);
+		return;
+	}
+
+	const int rowCount = _stalkingField.rowCount();
+	const int columnCount = _stalkingField.columnCount();
+	if (rowCount == 0 || columnCount == 0) {
+		return;
+	}
+
+	// Outline each cell boundary and the center of each cell to visualize the stalking field.
+	const Common::Point drawOrigin =  getBbox().origin() + displayContext._origin;
+	const int16 markerRadius = 4;
+	const Graphics::Palette currentPalette = g_system->getPaletteManager()->grabPalette(0, Graphics::PALETTE_COUNT);
+	const byte blackPaletteIndex = currentPalette.findBestColor(0, 0, 0);
+	const byte greenPaletteIndex = currentPalette.findBestColor(0, 255, 0);
+	const byte redPaletteIndex = currentPalette.findBestColor(255, 0, 0);
+	for (int row = 0; row < rowCount; ++row) {
+		for (int column = 0; column < columnCount; ++column) {
+			Common::Rect cellBounds(
+				(static_cast<int32>(column) *  getBbox().width()) / columnCount,
+				(static_cast<int32>(row) *  getBbox().height()) / rowCount,
+				(static_cast<int32>(column + 1) *  getBbox().width()) / columnCount,
+				(static_cast<int32>(row + 1) *  getBbox().height()) / rowCount
+			);
+			cellBounds.translate(drawOrigin.x, drawOrigin.y);
+			const bool cellContainsObstacle = _stalkingField.at(row, column) != 0;
+			const byte cellOutlinePaletteIndex = cellContainsObstacle ? redPaletteIndex : blackPaletteIndex;
+			targetSurface->frameRect(cellBounds, cellOutlinePaletteIndex);
+
+			Common::Point cellBoundsCenterInScreenCoordinates = cellBounds.center();
+			targetSurface->fillRect(
+				Common::Rect::center(
+					cellBoundsCenterInScreenCoordinates.x, cellBoundsCenterInScreenCoordinates.y,
+					markerRadius, markerRadius),
+				redPaletteIndex);
+		}
+	}
+
+	// Outline the area where Simba is allowed to move.
+	Common::Rect movementBoundsOnScreen = _simbaMovementBoundsInFieldCoordinates;
+	movementBoundsOnScreen.translate(drawOrigin.x, drawOrigin.y);
+	targetSurface->frameRect(movementBoundsOnScreen, greenPaletteIndex);
+
+	// Outline the bottom center position (Simba's starting point).
+	const Common::Point bottomCenterPositionInFieldCoordinates = _stalkingField.bottomCenterPositionInFieldCoordinates();
+	const Common::Point markerCenter = drawOrigin + bottomCenterPositionInFieldCoordinates;
+	targetSurface->fillRect(
+		Common::Rect::center(markerCenter.x, markerCenter.y, markerRadius, markerRadius),
+		redPaletteIndex);
+
+	// Draw the target point (Simba's ending point).
+	const Common::Point targetMarkerCenter = drawOrigin + _targetPosInFieldCoordinates;
+	targetSurface->fillRect(
+		Common::Rect::center(targetMarkerCenter.x, targetMarkerCenter.y, markerRadius, markerRadius),
+		redPaletteIndex);
+}
+
+void StalkingZazuActor::loadIsComplete() {
+	SpatialEntity::loadIsComplete();
+
+	// Shrink the movement bounds by half Simba's width. This is to keep Simba's
+	// center from reaching the center rather than its left edge.
+	_simbaMovementBoundsInFieldCoordinates = Common::Rect(0, 0, getBbox().width(), getBbox().height());
+	SpriteMovieActor *simbaActor = static_cast<SpriteMovieActor *>(g_engine->getImtGod()->getActorByIdAndType(_simbaActorId, kActorTypeSprite));
+	if (simbaActor != nullptr) {
+		// This was in the original to handle the case where the movement bounds are smaller than
+		// the simba's width after the inset. Since this actor is only used for one minigame with
+		// constant dimensions, we don't really need this code, but it is kept in here for robustness.
+		const int16 halfSimbaWidth = static_cast<int16>(simbaActor->getBbox().width() / 2.0);
+		_simbaMovementBoundsInFieldCoordinates.left += halfSimbaWidth;
+		_simbaMovementBoundsInFieldCoordinates.right -= halfSimbaWidth;
+		if (_simbaMovementBoundsInFieldCoordinates.left > _simbaMovementBoundsInFieldCoordinates.right) {
+			const int16 centerX = (_simbaMovementBoundsInFieldCoordinates.left + _simbaMovementBoundsInFieldCoordinates.right) / 2;
+			_simbaMovementBoundsInFieldCoordinates.left = centerX;
+			_simbaMovementBoundsInFieldCoordinates.right = centerX;
+		}
+	}
+
+	if (_obstaclesToShow != 0) {
+		generateStalkingEvent(kStalkingZazuResetStateEvent);
+		_gameMachine.runIfNotNested();
+	}
+}
+
+void StalkingZazuActor::onEvent(const ActorEvent &event) {
+	switch (event.type) {
+	case kMinigameSuccessEvent:
+	case kMinigameDefeatEvent:
+	case kMinigameCrouchedEvent:
+		runScriptResponseIfExists(event.type);
+		break;
+
+	default:
+		Actor::onEvent(event);
+	}
+}
+
+void StalkingZazuActor::timerEvent(const MediaStation::TimerEvent &event) {
+	generateTimerEvent(kStalkingZazuExpireTimerEvent);
+	_timerMachine.runIfNotNested();
+}
+
+void StalkingZazuActor::setZazuLook(bool zazuIsLooking) {
+	if (_zazuIsLooking != zazuIsLooking) {
+		_zazuIsLooking = zazuIsLooking;
+		if (zazuSeesSimba()) {
+			generateStalkingEvent(kStalkingZazuLoseStateEvent);
+			_gameMachine.runIfNotNested();
+		}
+	}
+}
+
+void StalkingZazuActor::setAudio(bool isEnabled) {
+	_soundEffectsEnabled = isEnabled;
+	if (!isEnabled) {
+		stopSound(_soundEffectId1);
+		stopSound(_soundEffectId2);
+	}
+}
+
+void StalkingZazuActor::startSound(uint soundActorId) {
+	if (soundActorId != 0) {
+		SoundActor *sound = static_cast<SoundActor *>(g_engine->getImtGod()->getActorByIdAndType(soundActorId, kActorTypeSound));
+		if (sound != nullptr) {
+			sound->start();
+		} else {
+			warning("[%s] %s: Null sound %d", debugName(), __func__, soundActorId);
+		}
+	}
+}
+
+void StalkingZazuActor::stopSound(uint soundActorId) {
+	if (soundActorId != 0) {
+		SoundActor *sound = static_cast<SoundActor *>(g_engine->getImtGod()->getActorByIdAndType(soundActorId, kActorTypeSound));
+		if (sound != nullptr) {
+			sound->stop();
+		} else {
+			warning("[%s] %s: Null sound %d", debugName(), __func__, soundActorId);
+		}
+	}
+}
+
+void StalkingZazuActor::setActive(bool isActive) {
+	if (_isActive != isActive) {
+		_isActive = isActive;
+		if (_isActive) {
+			generateTimerEvent(kStalkingZazuActivateTimerEvent);
+		} else {
+			_mouseIsInside = false;
+			_mouseIsDown = false;
+			generateTimerEvent(kStalkingZazuInactivateTimerEvent);
+		}
+		_timerMachine.runIfNotNested();
+
+		g_engine->generateMouseUpdateEvent();
+	}
+}
+
+void StalkingZazuActor::generateStalkingEvent(GameStateEvent event) {
+	_gameMachine.queueEvent(event);
+}
+
+void StalkingZazuActor::generateTimerEvent(StalkingZazuMinigame::TimerEvent event) {
+	_timerMachine.queueEvent(event);
+}
+
+StalkingZazuMinigame::Direction StalkingZazuActor::directionZazuWillLook() {
+	Common::Point delta = _targetPosInFieldCoordinates - _simbaPosInFieldCoordinates;
+	const double angleRadians = atan2(delta.y, delta.x);
+	const double angle = Math::rad2deg<double>(angleRadians);
+
+	if (angle >= -90 && angle < 90) {
+		return Direction::kEast;
+	} else {
+		return Direction::kWest;
+	}
+}
+
+void StalkingZazuActor::calcWhichObstaclesToShow(uint16 obstaclesToShow) {
+	int obstaclesToRemove = _obstacleActorIds.size() - obstaclesToShow;
+	if (obstaclesToRemove > 0) {
+		// The original removes obstacles at evenly distributed intervals. This ensures
+		// the remaining obstacles are spread out rather than clustered. However, this
+		// code doesn't seem to be reached in the original because scripts never seem to
+		// request to show fewer obstacles.
+		warning("%s: Showing all %d obstacles", __func__, obstaclesToShow);
+	}
+}
+
+void StalkingZazuActor::calcObstaclePlacements() {
+	_stalkingField.clear();
+	_stalkingField.placeObstacles(_obstacleActorIds);
+}
+
+void StalkingZazuActor::placeObstaclesOnStage() {
+	for (int row = 0; row < _stalkingField.rowCount(); ++row) {
+		for (int column = 0; column < _stalkingField.columnCount(); ++column) {
+			const uint16 obstacleActorId = _stalkingField.at(row, column);
+			if (obstacleActorId != 0) {
+				const CellCoord cell(static_cast<int16>(column), static_cast<int16>(row));
+				placeAndShowActor(obstacleActorId, _stalkingField.centerOfCell(cell));
+			}
+		}
+	}
+}
+
+void StalkingZazuActor::positionSimbaStartOnStage() {
+	_simbaPosInFieldCoordinates = _stalkingField.bottomCenterPositionInFieldCoordinates();
+	// This magic constant is in the original; not sure exactly why such a small offset is needed.
+	_simbaPosInFieldCoordinates.x += 3;
+	placeAndShowActor(_simbaActorId, _simbaPosInFieldCoordinates);
+
+	const Direction startDirection(Direction::kNorth);
+	setSimbaClipFromDirection(startDirection);
+	setSimbaCrouchClip(true);
+}
+
+void StalkingZazuActor::placeAndShowActor(uint actorId, const Common::Point &destInFieldCoordinates) {
+	SpatialEntity *actorToPlace = g_engine->getImtGod()->getSpatialEntityById(actorId);
+	if (actorToPlace == nullptr) {
+		warning("[%s] %s: Got null actor %d", debugName(), __func__, actorId);
+		return;
+	}
+
+	uint zCoord = zCoordOfPoint(destInFieldCoordinates);
+	actorToPlace->setZIndex(zCoord);
+
+	// Convert field coordinates to screen coordinates and center the actor directly over
+	// the destInFieldCoordinates.
+	Common::Point destInScreenCoordinates = destInFieldCoordinates + getBbox().origin();
+	// We could use moveToCentered here, but the engine for this minigame didn't seem
+	// to have it and did the centering calculation manually. So to avoid confusion when comparing
+	// with disassembly, we will also do the centering calculation manually here.
+	destInScreenCoordinates.x -= actorToPlace->getBbox().width() / 2;
+	destInScreenCoordinates.y -= actorToPlace->getBbox().height() / 2;
+	actorToPlace->moveTo(destInScreenCoordinates.x, destInScreenCoordinates.y);
+
+	Common::Array<ScriptValue> emptyArgs(0);
+	actorToPlace->callMethod(kSpatialShowMethod, emptyArgs);
+}
+
+int StalkingZazuActor::zCoordOfPoint(const Common::Point &point) {
+	// Items lower in the field (higher y) should appear in front of items higher up.
+	return zIndex() - point.y;
+}
+
+bool StalkingZazuActor::simbaIsInSuccessPosition() {
+	Common::Point delta = _targetPosInFieldCoordinates - _simbaPosInFieldCoordinates;
+	const double distanceToTarget = Math::hypotenuse<double>(static_cast<double>(delta.x), static_cast<double>(delta.y));
+	return (distanceToTarget <= _successRadius);
+}
+
+void StalkingZazuActor::setSimbaCrouchClip(bool isCrouchClipSet) {
+	if (isCrouchClipSet != _crouchClipIsSet) {
+		uint index = _stalkClips.indexOf(_currentDirection, isCrouchClipSet);
+		setClipOfMovie(_simbaActorId, _stalkClips[index]);
+		_crouchClipIsSet = isCrouchClipSet;
+	}
+}
+
+void StalkingZazuActor::processMove(Common::Point mousePos) {
+	Common::Point targetPosInFieldCoordinates = mousePos - getBbox().origin();
+	debugC(5, kDebugMinigame, "[%s] %s: currentPos: (%d, %d); dest: (%d, %d)", debugName(), __func__,
+		_simbaPosInFieldCoordinates.x, _simbaPosInFieldCoordinates.y, targetPosInFieldCoordinates.x, targetPosInFieldCoordinates.y);
+
+	Common::Point newPosInFieldCoordinates;
+	Direction direction;
+	bool simbaMoved = getMoveInformation(targetPosInFieldCoordinates, newPosInFieldCoordinates, direction);
+	if (simbaMoved) {
+		setSimbaClipFromDirection(direction);
+		if (isClearAtPoint(newPosInFieldCoordinates)) {
+			placeAndShowActor(_simbaActorId, newPosInFieldCoordinates);
+			SpriteMovieActor *actor = static_cast<SpriteMovieActor *>(g_engine->getImtGod()->getActorByIdAndType(_simbaActorId, kActorTypeSprite));
+			if (actor != nullptr) {
+				actor->activateNextFrame();
+			}
+
+			_simbaPosInFieldCoordinates = newPosInFieldCoordinates;
+		}
+	}
+}
+
+bool StalkingZazuActor::getMoveInformation(const Common::Point &targetPosInFieldCoordinates, Common::Point &nextPosInFieldCoordinates, Direction &direction) {
+	if (targetPosInFieldCoordinates == _simbaPosInFieldCoordinates) {
+		return false;
+	}
+
+	const Common::Point delta = targetPosInFieldCoordinates - _simbaPosInFieldCoordinates;
+	direction = directionOfVector(delta);
+	nextPosInFieldCoordinates = desiredPositionFromVectorInFieldCoordinates(delta);
+	if (nextPosInFieldCoordinates == _simbaPosInFieldCoordinates) {
+		return false;
+	}
+
+	return true;
+}
+
+Direction StalkingZazuActor::directionOfVector(const Common::Point &dest) {
+	const double angleRadians = atan2(static_cast<double>(dest.y), static_cast<double>(dest.x));
+	const double angle = Math::rad2deg<double>(angleRadians);
+
+	// This actor cannot move south. Any southerly vector is treated as
+	// either East or West depending on its horizontal component.
+	if (angle >= 90.0) return Direction::kWest;
+	else if (angle >= -22.5) return Direction::kEast;
+	else if (angle >= -67.5) return Direction::kNorthEast;
+	else if (angle >=-112.5) return Direction::kNorth;
+	else if (angle >=-157.5) return Direction::kNorthWest;
+	else return Direction::kWest;
+}
+
+Common::Point StalkingZazuActor::desiredPositionFromVectorInFieldCoordinates(const Common::Point &delta) {
+	const double length = Math::hypotenuse<double>(static_cast<double>(delta.x), static_cast<double>(delta.y));
+	double scale = 0.0;
+	if (length != 0.0) {
+		scale = static_cast<double>(_scalingFactor) / length;
+	}
+
+	int16 dx = static_cast<int16>(round(delta.x * scale));
+	int16 dy = static_cast<int16>(round(delta.y * scale));
+
+	// Don't allow the normalized vector to overshoot the original.
+	if (dy < 0) {
+		dy = MAX(dy, delta.y);
+	} else {
+		// Simba isn't allowed to backtrack.
+		dy = 0;
+	}
+
+	if (dx > 0) {
+		dx = MIN(dx, delta.x);
+	} else {
+		dx = MAX(dx, delta.x);
+	}
+
+	Common::Point nextPosInFieldCoordinates(_simbaPosInFieldCoordinates.x + dx, _simbaPosInFieldCoordinates.y + dy);
+	debugC(4, kDebugMinigame, "[%s] %s: (%d, %d): (%d, %d) + (%d, %d) = (%d, %d)",
+		debugName(), __func__, delta.x, delta.y,
+		_simbaPosInFieldCoordinates.x, _simbaPosInFieldCoordinates.y, dx, dy,
+		nextPosInFieldCoordinates.x, nextPosInFieldCoordinates.y);
+
+	bool nextPosSuccessfullyConstrained = _simbaMovementBoundsInFieldCoordinates.constrain(nextPosInFieldCoordinates.x, nextPosInFieldCoordinates.y, 0, 0);
+	if (!nextPosSuccessfullyConstrained) {
+		warning("[%s] %s: Point constraint failed", debugName(), __func__);
+	}
+
+	return nextPosInFieldCoordinates;
+}
+
+void StalkingZazuActor::setSimbaClipFromDirection(Direction direction) {
+	if (_currentDirection != direction || _crouchClipIsSet) {
+		uint stalkClipIndex = _stalkClips.indexOf(direction, false);
+		setClipOfMovie(_simbaActorId, _stalkClips[stalkClipIndex]);
+		_crouchClipIsSet = false;
+		_currentDirection = direction;
+		debugC(5, kDebugMinigame, "[%s] %s: Simba is facing %d", debugName(), __func__, static_cast<uint>(direction));
+	}
+}
+
+void StalkingZazuActor::setClipOfMovie(uint spriteActorId, uint clipId) {
+	SpriteMovieActor *movie = static_cast<SpriteMovieActor *>(g_engine->getImtGod()->getActorByIdAndType(_simbaActorId, kActorTypeSprite));
+	if (movie != nullptr) {
+		movie->setCurrentClip(clipId);
+	}
+}
+
+bool StalkingZazuActor::isClearAtPoint(const Common::Point &posInFieldCoordinates) {
+	// Rather than testing every tile the rectangle overlaps, the code assumes the rectangle is
+	// at most one tile wide/high (here it’s half a tile), and walls occupy whole tiles. Under those
+	// assumptions, checking the four corners completely determines whether the rectangle intersects
+	// any blocked tile.
+	const Common::Point cellSize = _stalkingField.cellSize();
+	const int16 halfWidth = cellSize.x / 4;
+	const int16 halfHeight = cellSize.y / 4;
+
+	Common::Rect box(
+		Common::Point(posInFieldCoordinates.x - halfWidth, posInFieldCoordinates.y - halfHeight),
+		halfWidth * 2, halfHeight * 2);
+	box.clip(_simbaMovementBoundsInFieldCoordinates);
+
+	const int16 rightEdge = MAX<int16>(box.right - 1, box.left);
+	const int16 bottomEdge = MAX<int16>(box.bottom - 1, box.top);
+	const Common::Point corners[4] = {
+		Common::Point(box.left, box.top),
+		Common::Point(box.left, bottomEdge),
+		Common::Point(rightEdge, bottomEdge),
+		Common::Point(rightEdge, box.top)
+	};
+
+	for (const Common::Point &corner : corners) {
+		const CellCoord cell = _stalkingField.cellAtPoint(corner);
+		if (_stalkingField.at(cell) != 0) {
+			return false;
+		}
+	}
+
+	return true;
+}
+
+void StalkingZazuActor::stalkingState_1_reset() {
+	_obstaclesToShow = MIN(_obstaclesToShow, (int)_obstacleActorIds.size());
+	if (_obstaclesToShow >= 0) {
+		calcWhichObstaclesToShow(_obstaclesToShow);
+		calcObstaclePlacements();
+	}
+
+	// hideObstacles
+	for (uint obstacleActorId : _obstacleActorIds) {
+		Actor *obstacle = g_engine->getImtGod()->getActorById(obstacleActorId);
+		if (obstacle != nullptr) {
+			Common::Array<ScriptValue> emptyArgs(0);
+			obstacle->callMethod(kSpatialHideMethod, emptyArgs);
+		}
+	}
+
+	placeObstaclesOnStage();
+	positionSimbaStartOnStage();
+	_simbaIsCrouching = false;
+	g_engine->generateMouseUpdateEvent();
+
+	setZazuLook(false);
+	_acceptingMouseEvents = true;
+}
+
+void StalkingZazuActor::stalkingState_2_crouched() {
+	if (!_simbaIsCrouching) {
+		_simbaIsCrouching = true;
+		g_engine->generateMouseUpdateEvent();
+
+		ActorEvent actorEvent(_id, kMinigameCrouchedEvent);
+		g_engine->getEventLoop()->queueEvent(actorEvent);
+	}
+
+	setSimbaCrouchClip(true);
+	if (_soundEffectsEnabled) {
+		startSound(_soundEffectId1);
+	}
+	_acceptingMouseEvents = false;
+}
+
+void StalkingZazuActor::stalkingState_3_canMove() {
+	setSimbaCrouchClip(false);
+	if (_soundEffectsEnabled) {
+		startSound(_soundEffectId1);
+	}
+
+	if (zazuSeesSimba()) {
+		generateStalkingEvent(kStalkingZazuLoseStateEvent);
+		_gameMachine.runIfNotNested();
+		_acceptingMouseEvents = false;
+	}
+
+	generateTimerEvent(kStalkingZazuQueueTimerEvent);
+	_timerMachine.runIfNotNested();
+}
+
+void StalkingZazuActor::stalkingState_4_processMove() {
+	if (zazuSeesSimba()) {
+		debugC(5, kDebugMinigame, "[%s] %s: LOSE", debugName(), __func__);
+		generateStalkingEvent(kStalkingZazuLoseStateEvent);
+		_gameMachine.runIfNotNested();
+
+	} else if (simbaIsInSuccessPosition()) {
+		debugC(5, kDebugMinigame, "[%s] %s: WIN", debugName(), __func__);
+		generateStalkingEvent(kStalkingZazuWinStateEvent);
+		_gameMachine.runIfNotNested();
+
+	} else {
+		Common::Point mousePos;
+		currentMousePosition(mousePos);
+		processMove(mousePos);
+		if (_soundEffectsEnabled) {
+			startSound(_soundEffectId2);
+		}
+
+		generateTimerEvent(kStalkingZazuQueueTimerEvent);
+		_timerMachine.runIfNotNested();
+	}
+}
+
+void StalkingZazuActor::stalkingState_5_defeat() {
+	ActorEvent actorEvent(_id, kMinigameDefeatEvent);
+	g_engine->getEventLoop()->queueEvent(actorEvent);
+}
+
+void StalkingZazuActor::stalkingState_6_success() {
+	ActorEvent actorEvent(_id, kMinigameSuccessEvent);
+	g_engine->getEventLoop()->queueEvent(actorEvent);
+}
+
+void StalkingZazuActor::timerState_0_expired() {
+	generateStalkingEvent(kStalkingZazuMoveStateEvent);
+	_gameMachine.runIfNotNested();
+}
+
+void StalkingZazuActor::timerState_1_queued() {
+	g_engine->getTimerService()->startTimer(_timer, _timerDurationInSeconds);
+}
+
+uint16 StalkingZazuActor::findActorToAcceptMouseEvents(
+	const Common::Point &point,
+	uint16 eventMask,
+	MouseActorState &state,
+	bool clipMouseEvents) {
+	(void)clipMouseEvents;
+
+	uint16 result = 0;
+	if (_isActive) {
+		const bool isMousePositionInteresting = mousePositionIsInteresting(point);
+		if (isMousePositionInteresting) {
+			if (eventMask & kMouseDownFlag) {
+				state.mouseDown = this;
+				result |= kMouseDownFlag;
+			}
+
+			if (eventMask & kMouseEnterFlag) {
+				state.mouseEnter = this;
+				result |= kMouseEnterFlag;
+			}
+
+			if (eventMask & kMouseMovedFlag) {
+				state.mouseMoved = this;
+				result |= kMouseMovedFlag;
+			}
+		}
+
+		if (_mouseIsInside && (eventMask & kMouseExitFlag)) {
+			state.mouseExit = this;
+			result |= kMouseExitFlag;
+		}
+
+		if (_mouseIsDown && (eventMask & kMouseUpFlag)) {
+			state.mouseUp = this;
+			result |= kMouseUpFlag;
+		}
+	}
+
+	return result;
+}
+
+bool StalkingZazuActor::mousePositionIsInteresting(const Common::Point &pos) {
+	bool isInteresting = true;
+	if (_acceptingMouseEvents) {
+		SpriteMovieActor *simba = static_cast<SpriteMovieActor *>(g_engine->getImtGod()->getActorByIdAndType(_simbaActorId, kActorTypeSprite));
+		if (simba == nullptr) {
+			isInteresting = false;
+		}
+		isInteresting = simba->getBbox().contains(pos);
+	}
+	return isInteresting;
+}
+
+void StalkingZazuActor::mouseDownEvent(const MouseEvent &event) {
+	_mouseIsDown = true;
+	generateStalkingEvent(kStalkingZazuCrouchStateEvent);
+	_gameMachine.runIfNotNested();
+}
+
+void StalkingZazuActor::mouseUpEvent(const MouseEvent &event) {
+	_mouseIsDown = false;
+	generateStalkingEvent(kStalkingZazuUncrouchStateEvent);
+	_gameMachine.runIfNotNested();
+}
+
+void StalkingZazuActor::mouseEnteredEvent(const MouseEvent &event) {
+	_mouseIsInside = true;
+	if (_cursorResourceId != 0) {
+		g_engine->getCursorManager()->setAsTemporary(_cursorResourceId);
+	} else {
+		g_engine->getCursorManager()->unsetTemporary();
+	}
+}
+
+void StalkingZazuActor::mouseExitedEvent(const MouseEvent &event) {
+	_mouseIsInside = false;
+}
+
+bool StalkingZazuActor::zazuSeesSimba() {
+	return _zazuIsLooking && !simbaIsBehindRock();
+}
+
+bool StalkingZazuActor::simbaIsBehindRock() {
+	// Simba is behind the rock iff the rock is directly
+	// north of the field cell he is currently in.
+	bool isBehindRock = false;
+	CellCoord currentCell = _stalkingField.cellAtPoint(_simbaPosInFieldCoordinates);
+
+	CellCoord cellNorthOfCurrent = currentCell.getAdjacentCell(Direction::kNorth);
+	if (_stalkingField.isInBounds(cellNorthOfCurrent)) {
+		uint16 cellValue = _stalkingField.at(cellNorthOfCurrent);
+		if (cellValue != 0) {
+			isBehindRock = true;
+		}
+	}
+
+	return isBehindRock;
+}
+
+Common::String StalkingZazuActor::debugString() const {
+	return Common::String::format("simbaActorId: %u\n", _simbaActorId);
+}
+
+} // End namespace MediaStation
diff --git a/engines/mediastation/minigames/stalkingzazu.h b/engines/mediastation/minigames/stalkingzazu.h
new file mode 100644
index 00000000000..9e3bb1d1fdf
--- /dev/null
+++ b/engines/mediastation/minigames/stalkingzazu.h
@@ -0,0 +1,288 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef MEDIASTATION_MINIGAMES_STALKINGZAZU_H
+#define MEDIASTATION_MINIGAMES_STALKINGZAZU_H
+
+#include "mediastation/statemachine.h"
+#include "mediastation/actors/stage.h"
+
+namespace MediaStation {
+
+class StalkingZazuActor;
+namespace StalkingZazuMinigame {
+
+class Direction {
+public:
+	static const uint TOTAL_DIRECTIONS = 8;
+	enum Value {
+		kNone = 0,
+		kEast = 1,
+		kNorthEast = 2,
+		kNorth = 3,
+		kNorthWest = 4,
+		kWest = 5,
+		kSouthWest = 6,
+		kSouth = 7,
+		kSouthEast = 8
+	};
+
+	Direction() {}
+	Direction(Value value) : _value(value) {}
+	Direction(uint directionToken);
+
+	// Conversion operator so a Direction stands in for its enum value directly.
+	operator Value() const { return _value; }
+
+	Direction counterClockwise() const { return rotate(1); }
+	Direction opposite() const { return rotate(TOTAL_DIRECTIONS / 2); }
+
+private:
+	// Rotates counter-clockwise by the given number of eighth-turns,
+	Direction rotate(uint steps) const;
+	Value _value = kNone;
+};
+
+class CellCoord : public Common::Point {
+public:
+	CellCoord() : Common::Point() {}
+	CellCoord(int16 column, int16 row) : Common::Point(column, row) {}
+
+	CellCoord getAdjacentCell(const Direction &direction) const;
+};
+
+class StalkClips : public Common::Array<uint> {
+public:
+	static const uint SIZE = 18;
+	uint indexOf(Direction direction, bool isCrouched);
+};
+
+// This is basically a uint16 matrix with a lot of additional methods attached.
+// The ScummVM Matrix class was not used because it is float-backed and so not really
+// a great choice for an obstacle field. This is really a translation layer a logical
+// "stalking field" space that defines whether a given region of graphical space contains
+// an obstacle or not.
+class StalkingField {
+public:
+	StalkingField() = default;
+	StalkingField(int rowCount, int columnCount, const Common::Point &graphicalSize);
+
+	uint16 &at(int row, int column);
+	const uint16 &at(int row, int column) const;
+	uint16 &at(const CellCoord &coord);
+	const uint16 &at(const CellCoord &coord) const;
+	bool isEmpty(const CellCoord &coord) { return at(coord) == 0; }
+
+	bool isInBounds(int row, int column) const;
+	bool isInBounds(const CellCoord &coord) const;
+
+	int rowCount() const { return _rows; }
+	int columnCount() const { return _columns; }
+
+	void clear();
+	Common::Point bottomCenterPositionInFieldCoordinates();
+	CellCoord cellAtPoint(const Common::Point &point);
+	void placeObstacles(const Common::Array<uint> &obstacleActorIds);
+	Common::Point cellSize();
+	Common::Point centerOfCell(const CellCoord &coord);
+
+private:
+	bool addObstacleToRow(uint16 obstacleActorId, uint16 row);
+	Common::Array<CellCoord> calcAvailableCellsOnRow(uint row);
+	Common::Array<uint16> calcPartitionBoundaries(uint end, uint brk); // calcBreakPoints
+
+	bool obstacleCanBePlaced(const CellCoord &coord); // cellIsValidObstacle
+	bool obstacleCanBePlaced(CellCoord cellCoord, Direction direction, Common::Array<CellCoord> &cellCoordVector);
+
+	int _columns = 0;
+	int _rows = 0;
+	Common::Array<uint16> _values;
+	Common::Point _graphicalSize;
+};
+
+enum GameState {
+	kStalkingZazuStateStart = 0,
+	kStalkingZazuStateReset = 1,
+	kStalkingZazuStateCrouched = 2,
+	kStalkingZazuStateCanMove = 3,
+	kStalkingZazuStateProcessMove = 4,
+	kStalkingZazuStateDefeat = 5,
+	kStalkingZazuStateSuccess = 6
+};
+
+enum GameStateEvent {
+	kStalkingZazuResetStateEvent = 0,
+	kStalkingZazuCrouchStateEvent = 1,
+	kStalkingZazuUncrouchStateEvent = 2,
+	kStalkingZazuMoveStateEvent = 3,
+	kStalkingZazuLoseStateEvent = 4,
+	kStalkingZazuWinStateEvent = 5
+};
+
+struct GameStateMachineEvent : public StateMachineEvent<GameState> {
+public:
+	uint obstaclesToShow = 0;
+};
+
+class GameStateMachine : public StateMachine<GameState, GameStateEvent> {
+public:
+	GameStateMachine(StalkingZazuActor *actor) : _actor(actor) { _currentState = kStalkingZazuStateStart; }
+
+protected:
+	virtual void executeNextState(GameStateEvent eventType) override;
+	StalkingZazuActor *_actor = nullptr;
+};
+
+enum TimerState {
+	kStalkingZazuExpiredTimerState = 0,
+	kStalkingZazuQueuedTimerState = 1,
+	kStalkingZazuExpiredInactiveTimerState = 2,
+	kStalkingZazuExpiredActiveTimerState = 3,
+	kStalkingZazuQueuedInactiveTimerState = 4
+};
+
+enum TimerEvent {
+	kStalkingZazuActivateTimerEvent = 0,
+	kStalkingZazuInactivateTimerEvent = 1,
+	kStalkingZazuExpireTimerEvent = 2,
+	kStalkingZazuQueueTimerEvent = 3
+};
+
+class TimerStateMachine : public StateMachine<TimerState, TimerEvent> {
+public:
+	TimerStateMachine(StalkingZazuActor *actor) : _actor(actor) { _currentState = kStalkingZazuExpiredTimerState; }
+
+protected:
+	virtual void executeNextState(TimerEvent eventType) override;
+	StalkingZazuActor *_actor = nullptr;
+};
+
+}; // End of namespace StalkingZazuMinigame
+
+class StalkingZazuActor : public SpatialEntity {
+friend class StalkingZazuMinigame::GameStateMachine;
+friend class StalkingZazuMinigame::TimerStateMachine;
+
+public:
+	StalkingZazuActor() : SpatialEntity(kActorTypeStalkingZazu), _gameMachine(this), _timerMachine(this) {};
+
+	virtual Common::String debugString() const override;
+	virtual void readParameter(Chunk &chunk, ActorHeaderSectionType paramType) override;
+	virtual ScriptValue callMethod(BuiltInMethod methodId, Common::Array<ScriptValue> &args) override;
+	virtual bool isVisible() const override;
+	virtual void draw(DisplayContext &displayContext) override;
+	virtual void loadIsComplete() override;
+
+	virtual void onEvent(const ActorEvent &event) override;
+	virtual void timerEvent(const MediaStation::TimerEvent &event) override;
+
+private:
+	StalkingZazuMinigame::GameStateMachine _gameMachine;
+	void generateStalkingEvent(StalkingZazuMinigame::GameStateEvent event);
+	StalkingZazuMinigame::TimerStateMachine _timerMachine;
+	void generateTimerEvent(StalkingZazuMinigame::TimerEvent event);
+
+	void calcWhichObstaclesToShow(uint16 obstaclesToShow);
+	void calcObstaclePlacements();
+	void placeObstaclesOnStage();
+	void positionSimbaStartOnStage();
+
+	bool simbaIsInSuccessPosition();
+	void setSimbaCrouchClip(bool isSimbaCrouching);
+	void setSimbaClipFromDirection(StalkingZazuMinigame::Direction direction);
+	bool zazuSeesSimba();
+	bool simbaIsBehindRock();
+
+	void placeAndShowActor(uint obstacleActorId, const Common::Point &pos);
+	void setClipOfMovie(uint spriteActorId, uint clipId);
+	int zCoordOfPoint(const Common::Point &point);
+
+	void processMove(Common::Point dest);
+	bool getMoveInformation(const Common::Point &targetPos, Common::Point &nextPos, StalkingZazuMinigame::Direction &direction);
+	StalkingZazuMinigame::Direction directionOfVector(const Common::Point &dest);
+	Common::Point desiredPositionFromVectorInFieldCoordinates(const Common::Point &point);
+	bool isClearAtPoint(const Common::Point &point);
+
+	StalkingZazuMinigame::Direction directionZazuWillLook();
+	void setZazuLook(bool isLooking);
+	void setActive(bool isActive);
+
+	void stalkingState_1_reset();
+	void stalkingState_2_crouched();
+	void stalkingState_3_canMove();
+	void stalkingState_4_processMove();
+	void stalkingState_5_defeat();
+	void stalkingState_6_success();
+	void timerState_0_expired();
+	void timerState_1_queued();
+	// The original has a few other timer states that are no-ops,
+	// so they are excluded here.
+
+	virtual bool interactsWithMouse() const override { return _isActive; }
+	virtual uint16 findActorToAcceptMouseEvents(
+		const Common::Point &point,
+		uint16 eventMask,
+		MouseActorState &state,
+		bool clipMouseEvents) override;
+	bool mousePositionIsInteresting(const Common::Point &pos);
+	void mouseDownEvent(const MouseEvent &event) override;
+	void mouseUpEvent(const MouseEvent &event) override;
+	void mouseEnteredEvent(const MouseEvent &event) override;
+	void mouseExitedEvent(const MouseEvent &event) override;
+	bool _mouseIsDown = false;
+	bool _mouseIsInside = false;
+
+	StalkingZazuMinigame::StalkingField _stalkingField;
+	StalkingZazuMinigame::StalkClips _stalkClips;
+	Common::Point _targetPosInFieldCoordinates;
+	Common::Point _simbaPosInFieldCoordinates;
+	Common::Rect _simbaMovementBoundsInFieldCoordinates; // Offset 0x104 in original (FP68K calls obscure xrefs).
+	StalkingZazuMinigame::Direction _currentDirection;
+
+	uint _cursorResourceId = 0;
+	uint _simbaActorId = 0;
+	int _obstaclesToShow = 0;
+	Common::Array<uint> _obstacleActorIds;
+
+	bool _isActive = false;
+	bool _simbaIsCrouching = false;
+	bool _crouchClipIsSet = false;
+	bool _acceptingMouseEvents = true;
+	bool _zazuIsLooking = false;
+
+	double _timerDurationInSeconds = 0.10;
+	uint _scalingFactor = 10; // Offset 0xD8 in original (FP68K calls obscure xrefs).
+	uint _successRadius = 20; // Offset 0xFE in original (FP68K calls obscure xrefs).
+
+	// These are read in the original, but they don't seem to actually be populated
+	// and sound effects don't seem to be enabled. However, they are included in case
+	// they are used in some localization.
+	bool _soundEffectsEnabled = false;
+	uint _soundEffectId1 = 0;
+	uint _soundEffectId2 = 0;
+	void setAudio(bool isEnabled);
+	void startSound(uint soundActorId);
+	void stopSound(uint soundActorId);
+};
+
+} // End namespace MediaStation
+
+#endif
diff --git a/engines/mediastation/module.mk b/engines/mediastation/module.mk
index 3352d0e9baf..928d77fe143 100644
--- a/engines/mediastation/module.mk
+++ b/engines/mediastation/module.mk
@@ -40,6 +40,7 @@ MODULE_OBJS = \
 	metaengine.o \
 	minigames/dotgame.o \
 	minigames/sophie.o \
+	minigames/stalkingzazu.o \
 	profile.o
 
 # This module can be built as a plugin
diff --git a/engines/mediastation/statemachine.h b/engines/mediastation/statemachine.h
new file mode 100644
index 00000000000..30bdcc5a9bc
--- /dev/null
+++ b/engines/mediastation/statemachine.h
@@ -0,0 +1,84 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef MEDIASTATION_STATEMACHINE_H
+#define MEDIASTATION_STATEMACHINE_H
+
+#include "common/textconsole.h"
+#include "common/queue.h"
+
+namespace MediaStation {
+
+template<typename EventType>
+struct StateMachineEvent {
+	EventType eventType;
+};
+
+// The original had a very generalized finite state machine supported by state matrix, function pointer,
+// and other classes, but that was judged needlessly complex to reimplement here. The state transition logic
+// is embedded directly into this class.
+template<typename StateType, typename EventType>
+class StateMachine {
+public:
+	virtual ~StateMachine() {};
+
+	void queueEvent(EventType event);
+	void runIfNotNested();
+	void executeForever();
+
+protected:
+	StateType _currentState;
+	Common::Queue<EventType> _events;
+	bool _handlingEvents = false;
+	virtual void executeNextState(EventType eventType) = 0;
+	void warnOnInvalidTransition(EventType eventType);
+};
+
+template<typename StateType, typename EventType>
+void StateMachine<StateType, EventType>::queueEvent(EventType event) {
+	_events.push(event);
+}
+
+template<typename StateType, typename EventType>
+void StateMachine<StateType, EventType>::runIfNotNested() {
+	if (!_handlingEvents) {
+		_handlingEvents = true;
+		executeForever();
+		_handlingEvents = false;
+	}
+}
+
+template<typename StateType, typename EventType>
+void StateMachine<StateType, EventType>::executeForever() {
+	while (!_events.empty()) {
+		EventType eventType = _events.pop();
+		executeNextState(eventType);
+	}
+}
+
+template<typename StateType, typename EventType>
+void StateMachine<StateType, EventType>::warnOnInvalidTransition(EventType eventType) {
+	warning("Got invalid event %d for state %d", static_cast<uint>(eventType), static_cast<uint>(_currentState));
+}
+
+} // End of namespace MediaStation
+
+#endif


Commit: edb772820851c8ba92d6ddf007256f131725a3d3
    https://github.com/scummvm/scummvm/commit/edb772820851c8ba92d6ddf007256f131725a3d3
Author: Nathanael Gentry (nathanael.gentrydb8 at gmail.com)
Date: 2026-07-18T20:59:49-04:00

Commit Message:
MEDIASTATION: Implement hardcoded Dalmatians maze minigame

Assisted-by: gpt-5.5 claude-sonnet-4.5

Changed paths:
  A engines/mediastation/minigames/maze.cpp
  A engines/mediastation/minigames/maze.h
    engines/mediastation/actors/image.cpp
    engines/mediastation/mediascript/collection.cpp
    engines/mediastation/mediascript/function.cpp
    engines/mediastation/mediascript/function.h
    engines/mediastation/module.mk


diff --git a/engines/mediastation/actors/image.cpp b/engines/mediastation/actors/image.cpp
index 2aa6b991220..f0fecb0aac8 100644
--- a/engines/mediastation/actors/image.cpp
+++ b/engines/mediastation/actors/image.cpp
@@ -92,7 +92,7 @@ ScriptValue ImageActor::callMethod(BuiltInMethod methodId, Common::Array<ScriptV
 }
 
 void ImageActor::draw(DisplayContext &displayContext) {
-	if (_isVisible) {
+	if (_asset != nullptr && _asset->bitmap != nullptr) {
 		Common::Point origin = getBbox().origin();
 		g_engine->getDisplayManager()->imageBlit(origin, _asset->bitmap, _dissolveFactor, &displayContext);
 	}
diff --git a/engines/mediastation/mediascript/collection.cpp b/engines/mediastation/mediascript/collection.cpp
index ebf964f8fac..13b4e5e0dd7 100644
--- a/engines/mediastation/mediascript/collection.cpp
+++ b/engines/mediastation/mediascript/collection.cpp
@@ -80,11 +80,13 @@ ScriptValue Collection::callMethod(BuiltInMethod methodId, Common::Array<ScriptV
 
 	case kGetAtMethod: {
 		ARGCOUNTCHECK(1);
-		uint index = static_cast<uint>(args[0].asFloat());
-		if (index < size()) {
+		// Index can be -1, so make sure we check for that.
+		int index = static_cast<uint>(args[0].asFloat());
+		if (index >= 0 && (uint)index < size()) {
 			returnValue = operator[](index);
 		} else {
 			warning("%s: Index %d out of bounds %d", __func__, index, size());
+			returnValue.setToFloat(0.0);
 		}
 		break;
 	}
diff --git a/engines/mediastation/mediascript/function.cpp b/engines/mediastation/mediascript/function.cpp
index fac73f8f6c3..d9ef66a71c5 100644
--- a/engines/mediastation/mediascript/function.cpp
+++ b/engines/mediastation/mediascript/function.cpp
@@ -25,6 +25,7 @@
 #include "mediastation/mediascript/function.h"
 #include "mediastation/debugchannels.h"
 #include "mediastation/mediastation.h"
+#include "mediastation/minigames/maze.h"
 
 namespace MediaStation {
 
@@ -97,6 +98,8 @@ FunctionManager::~FunctionManager() {
 		delete it->_value;
 	}
 	_functions.clear();
+
+	delete _maze;
 }
 
 bool FunctionManager::attemptToReadFromStream(Chunk &chunk, uint sectionType) {
@@ -616,24 +619,20 @@ void FunctionManager::script_DebugPrint(Common::Array<ScriptValue> &args, Script
 	debug("%s", output.c_str());
 }
 
-void FunctionManager::script_MazeGenerate(Common::Array<ScriptValue> &args, ScriptValue &returnValue) {
-	warning("STUB: %s", __func__);
-}
-
-void FunctionManager::script_MazeApplyMoveMask(Common::Array<ScriptValue> &args, ScriptValue &returnValue) {
-	warning("STUB: %s", __func__);
-}
-
-void FunctionManager::script_MazeSolve(Common::Array<ScriptValue> &args, ScriptValue &returnValue) {
-	warning("STUB: %s", __func__);
-}
-
 void FunctionManager::script_BeginTimedInterval(Common::Array<ScriptValue> &args, ScriptValue &returnValue) {
-	warning("STUB: %s", __func__);
+	_timedIntervalStartInMs = g_engine->getTotalPlayTime();
 }
 
 void FunctionManager::script_EndTimedInterval(Common::Array<ScriptValue> &args, ScriptValue &returnValue) {
-	warning("STUB: %s", __func__);
+	uint32 now = g_engine->getTotalPlayTime();
+	if (now < _timedIntervalStartInMs) {
+		warning("%s: Timed interval ended before it started", __func__);
+		return;
+	}
+
+	const uint32 millisecondsElapsed = now - _timedIntervalStartInMs;
+	const double secondsElapsed = millisecondsElapsed / 1000.0;
+	returnValue.setToFloat(secondsElapsed);
 }
 
 void FunctionManager::script_Checkers(Common::Array<ScriptValue> &args, ScriptValue &returnValue) {
diff --git a/engines/mediastation/mediascript/function.h b/engines/mediastation/mediascript/function.h
index fc7432870e8..6a6f2ff1e22 100644
--- a/engines/mediastation/mediascript/function.h
+++ b/engines/mediastation/mediascript/function.h
@@ -54,6 +54,10 @@ private:
 	uint32 _bytecodeSize = 0;
 };
 
+namespace MazeMinigame {
+	class Maze;
+} // End of namespace MazeMinigame
+
 class FunctionManager : public ParameterClient {
 friend class Debugger;
 
@@ -66,10 +70,13 @@ public:
 	ScriptFunction *getFunctionById(uint functionId);
 	void deleteFunctionsForContext(uint contextId);
 
+	MazeMinigame::Maze *_maze = nullptr;
+
 	uint _scriptBlockCallDepth = 0;
 
 private:
 	Common::HashMap<uint, ScriptFunction *> _functions;
+	uint32 _timedIntervalStartInMs = 0.0;
 
 	void script_GetPlatform(Common::Array<ScriptValue> &args, ScriptValue &returnValue);
 	void script_Random(Common::Array<ScriptValue> &args, ScriptValue &returnValue);
diff --git a/engines/mediastation/minigames/maze.cpp b/engines/mediastation/minigames/maze.cpp
new file mode 100644
index 00000000000..48bb52d79c1
--- /dev/null
+++ b/engines/mediastation/minigames/maze.cpp
@@ -0,0 +1,279 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/algorithm.h"
+#include "common/queue.h"
+
+#include "mediastation/mediascript/function.h"
+#include "mediastation/mediastation.h"
+#include "mediastation/minigames/maze.h"
+
+namespace MediaStation {
+
+// Interestingly, the titles that have these maze functions don't seem to have
+// scripts that actually call them. Thus, for now only the maze functionality that
+// is known to be actually used is reimplemented, namely the overloaded script_MazeSolve
+// method and the methods it calls.
+void FunctionManager::script_MazeGenerate(Common::Array<ScriptValue> &args, ScriptValue &returnValue) {
+	// Doesn't seem to be actually called by scripts.
+	warning("STUB: %s", __func__);
+}
+
+void FunctionManager::script_MazeApplyMoveMask(Common::Array<ScriptValue> &args, ScriptValue &returnValue) {
+	// Doesn't seem to be actually called by scripts.
+	warning("STUB: %s", __func__);
+}
+
+void FunctionManager::script_MazeSolve(Common::Array<ScriptValue> &args, ScriptValue &returnValue) {
+	if (args.size() == 0) {
+		// newMaze_clear
+		delete _maze;
+		_maze = nullptr;
+
+	} else if (args.size() == 2) {
+		// newMaze_init
+		delete _maze;
+		_maze = new MazeMinigame::Maze(args);
+
+	} else if (args.size() == 4) {
+		// newMaze_solve
+		if (_maze != nullptr) {
+			_maze->solve(args, returnValue);
+		} else {
+			error("%s: Maze has not been initialized", __func__);
+		}
+
+	} else if (args.size() == 5) {
+		// Doesn't seem to be actually called by scripts.
+		error("%s: This pathfinding code has not been implemented due to no known titles that use it", __func__);
+
+	} else {
+		error("%s: Got invalid number of args", __func__);
+	}
+}
+
+MazeMinigame::Maze::Maze(const Common::Array<ScriptValue> &args) {
+	Collection *gridDataCollection = args[0].asCollection();
+	// args[1] is the precomputed routing table, which we won't
+	// use since we are just doing BFS.
+
+	const int16 rowCount = static_cast<int16>(gridDataCollection->operator[](gridDataCollection->size() - 2).asFloat());
+	const int16 columnCount = static_cast<int16>(gridDataCollection->operator[](gridDataCollection->size() - 1).asFloat());
+	_grid = new CellGrid(rowCount, columnCount);
+
+	// Read the per-cell direction masks out of the padded script layout into
+	// plain grid order (column outer, row inner).
+	Common::Array<int16> directionMasks;
+	directionMasks.reserve(rowCount * columnCount);
+	for (int16 column = 0; column < columnCount; column++) {
+		for (int16 row = 0; row < rowCount; row++) {
+			const int32 dataIndex = _grid->paddedIndexForCoord(CellCoord(row, column));
+			directionMasks.push_back(static_cast<int16>(gridDataCollection->operator[](dataIndex).asFloat()));
+		}
+	}
+
+	_grid->setDirections(directionMasks);
+}
+
+MazeMinigame::Maze::~Maze() {
+	delete _grid;
+	_grid = nullptr;
+}
+
+MazeMinigame::CellGraphType MazeMinigame::Cell::getType() const {
+	CellGraphType result = kObstacleCell;
+	switch (_directionMask) {
+	case 0x00:
+		result = kObstacleCell;
+		break;
+
+	// These other cases are technically not needed because only the segment table generation
+	// code used them. However, they are left here for completeness and later implementation
+	// of the original logic if desired.
+	case CellDirectionMask::kWest: // 0x01
+	case CellDirectionMask::kSouth: // 0x02
+	case CellDirectionMask::kEast: // 0x04
+	case CellDirectionMask::kNorth: // 0x08
+		result = kDeadEndNode;
+		break;
+
+	case (CellDirectionMask::kWest | CellDirectionMask::kSouth | CellDirectionMask::kEast): // 0x07
+	case (CellDirectionMask::kWest | CellDirectionMask::kSouth | CellDirectionMask::kNorth): // 0x0B
+	case (CellDirectionMask::kWest | CellDirectionMask::kEast | CellDirectionMask::kNorth): // 0x0D
+	case (CellDirectionMask::kSouth | CellDirectionMask::kEast | CellDirectionMask::kNorth): // 0x0E
+	case (CellDirectionMask::kWest | CellDirectionMask::kSouth | CellDirectionMask::kEast | CellDirectionMask::kNorth): // 0x0F
+		result = kJunctionNode;
+		break;
+
+	case (CellDirectionMask::kWest | CellDirectionMask::kSouth): // 0x03
+	case (CellDirectionMask::kWest | CellDirectionMask::kEast): // 0x05
+	case (CellDirectionMask::kSouth | CellDirectionMask::kEast): // 0x06
+	case (CellDirectionMask::kWest | CellDirectionMask::kNorth): // 0x09
+	case (CellDirectionMask::kSouth | CellDirectionMask::kNorth): // 0x0A
+	case (CellDirectionMask::kEast | CellDirectionMask::kNorth): // 0x0C
+		result = kCorridorCell;
+		break;
+
+	default:
+		warning("%s: Unexpected direction mask 0x%x", __func__, _directionMask);
+		break;
+  }
+
+  return result;
+}
+
+void MazeMinigame::Maze::solve(const Common::Array<ScriptValue> &args, ScriptValue &returnValue) {
+	// Cruella and the puppy arrive as 1-based row/column pairs.
+	const CellCoord cruellaCoord(
+		static_cast<int16>(args[0].asFloat() - 1),
+		static_cast<int16>(args[1].asFloat() - 1));
+	const CellCoord puppyCoord(
+		static_cast<int16>(args[2].asFloat() - 1),
+		static_cast<int16>(args[3].asFloat() - 1));
+
+	Collection *pathCollection = new Collection;
+	bool cruellaHasCaughtPuppy = (cruellaCoord == puppyCoord);
+	if (!cruellaHasCaughtPuppy) {
+		const Common::Array<CellCoord> path = _grid->shortestPath(cruellaCoord, puppyCoord);
+		if (path.empty()) {
+			// The maze is always fully connected, so this should never happen.
+			error("%s: No path found", __func__);
+		}
+
+		for (CellCoord coord : path) {
+			// Scripts expect these padded indices.
+			const int32 paddedGridIndex = _grid->paddedIndexForCoord(coord);
+			ScriptValue paddedGridIndexValue;
+			paddedGridIndexValue.setToFloat(paddedGridIndex);
+			pathCollection->push_back(paddedGridIndexValue);
+		}
+	}
+
+	// The return value owns this collection that holds the serialized
+	// path to send back to scripts.
+	returnValue.setToCollection(pathCollection);
+}
+
+int32 MazeMinigame::CellGrid::paddedIndexForCoord(CellCoord coord) {
+	// Maze data received from or sent to scripts is stored
+	// with a one-cell border of padding on each side.
+	return (coord.column + 1) * (_rows + 1) + (coord.row + 1);
+}
+
+MazeMinigame::CellGrid::CellGrid(int16 rowCount, int16 columnCount) : _rows(rowCount), _columns(columnCount) {
+	_cells.resize(_rows * _columns);
+}
+
+void MazeMinigame::CellGrid::setDirections(const Common::Array<int16> &directionMasks) {
+	// The masks arrive in grid order, so each linear slot is one cell.
+	for (uint cellIndex = 0; cellIndex < _cells.size(); cellIndex++) {
+		const CellCoord coord = coordForIndex(static_cast<int16>(cellIndex));
+		const int16 directionMask = directionMasks[cellIndex];
+		Cell &cell = at(coord);
+		cell = Cell(directionMask);
+
+		// Record the reachable neighbor in each open direction.
+		if (directionMask & CellDirectionMask::kWest) { // 0x01
+			cell._validNeighbors.push_back(indexForCoord(CellCoord(coord.row, coord.column - 1)));
+		}
+		if (directionMask & CellDirectionMask::kSouth) { // 0x02
+			cell._validNeighbors.push_back(indexForCoord(CellCoord(coord.row + 1, coord.column)));
+		}
+		if (directionMask & CellDirectionMask::kEast) { // 0x04
+			cell._validNeighbors.push_back(indexForCoord(CellCoord(coord.row, coord.column + 1)));
+		}
+		if (directionMask & CellDirectionMask::kNorth) { // 0x08
+			cell._validNeighbors.push_back(indexForCoord(CellCoord(coord.row - 1, coord.column)));
+		}
+	}
+}
+
+int16 MazeMinigame::CellGrid::indexForCoord(CellCoord coord) const {
+	return coord.row + coord.column * _rows;
+}
+
+MazeMinigame::CellCoord MazeMinigame::CellGrid::coordForIndex(int16 index) const {
+	return CellCoord(index % _rows, index / _rows);
+}
+
+MazeMinigame::Cell &MazeMinigame::CellGrid::at(CellCoord coord) {
+	return _cells[indexForCoord(coord)];
+}
+
+Common::Array<MazeMinigame::CellCoord> MazeMinigame::CellGrid::shortestPath(CellCoord start, CellCoord end) const {
+	Common::Array<CellCoord> path;
+	const Common::Array<uint> pathIndices = buildShortestPath(indexForCoord(start), indexForCoord(end));
+	for (uint cellIndex : pathIndices) {
+		path.push_back(coordForIndex(cellIndex));
+	}
+	return path;
+}
+
+Common::Array<uint> MazeMinigame::CellGrid::buildShortestPath(uint startIndex, uint endIndex) const {
+	const int32 NOT_VISITED = -1;
+	Common::Array<int32> predecessor(_cells.size(), NOT_VISITED);
+	predecessor[startIndex] = startIndex;
+
+	Common::Queue<uint> cellsToVisit;
+	cellsToVisit.push(startIndex);
+
+	// Do a standard BFS.
+	bool endWasReached = (predecessor[endIndex] != NOT_VISITED);
+	bool moreCellsToVisit = (!cellsToVisit.empty() && !endWasReached);
+	while (moreCellsToVisit) {
+		const uint currentCellIndex = cellsToVisit.pop();
+		const Cell &currentCell = _cells[currentCellIndex];
+
+		for (int16 neighborCellIndex : currentCell._validNeighbors) {
+			bool alreadyVisited = (predecessor[neighborCellIndex] != NOT_VISITED);
+			if (alreadyVisited) {
+				continue;
+			}
+
+			if (_cells[neighborCellIndex].getType() == kObstacleCell) {
+				continue;
+			}
+
+			// We have not seen this cell yet but it is valid
+			// to move there, so we need to check it out.
+			predecessor[neighborCellIndex] = currentCellIndex;
+			cellsToVisit.push(neighborCellIndex);
+		}
+
+		endWasReached = (predecessor[endIndex] != NOT_VISITED);
+		moreCellsToVisit = (!cellsToVisit.empty() && !endWasReached);
+	}
+
+	Common::Array<uint> path;
+	if (endWasReached) {
+		// Trace the path from the end to the start, then reverse
+		// it to get the ordering scripts expect.
+		for (uint cellIndex = endIndex; cellIndex != startIndex; cellIndex = predecessor[cellIndex]) {
+			path.push_back(cellIndex);
+		}
+		path.push_back(startIndex);
+		Common::reverse(path.begin(), path.end());
+	}
+
+	return path;
+};
+
+} // End of namespace MediaStation
diff --git a/engines/mediastation/minigames/maze.h b/engines/mediastation/minigames/maze.h
new file mode 100644
index 00000000000..722108ad44d
--- /dev/null
+++ b/engines/mediastation/minigames/maze.h
@@ -0,0 +1,113 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef MEDIASTATION_MINIGAMES_MAZE_H
+#define MEDIASTATION_MINIGAMES_MAZE_H
+
+#include "mediastation/actor.h"
+
+namespace MediaStation {
+
+namespace MazeMinigame {
+
+enum CellDirectionMask {
+	kWest = 0x01,
+	kSouth = 0x02,
+	kEast = 0x04,
+	kNorth = 0x08
+};
+
+enum CellGraphType {
+	kObstacleCell = 0,
+	kDeadEndNode = 1,
+	kJunctionNode = 2,
+	kCorridorCell = 3,
+};
+
+// A row/column position within the maze grid.
+struct CellCoord {
+	int16 row = 0;
+	int16 column = 0;
+
+	CellCoord(int16 row_ = 0, int16 column_ = 0) : row(row_), column(column_) {}
+
+	bool operator==(const CellCoord &other) const {
+		return row == other.row && column == other.column;
+	}
+};
+
+class Cell { // MazeElem
+public:
+	Cell(int16 directionMask = 0) : _directionMask(directionMask) {};
+
+	CellGraphType getType() const;
+	Common::Array<int16> _validNeighbors;
+
+private:
+	int16 _directionMask = 0;
+};
+
+// The original compressed the maze space down to "segments" (corridors connecting
+// junction/dead-end nodes) and used a pre-computed routing table for each level.
+// This seems way overkill for a 10x16 maze, so this reimplementation just does BFS.
+//
+// Another improvement from the original is that the linear cell indexing is purely
+// an implementation detail here. Callers address cells by coordinate and receive paths
+// back as coordinates.
+class CellGrid {
+public:
+	CellGrid(int16 rowCount, int16 columnCount);
+	void setDirections(const Common::Array<int16> &directionMasks);
+
+	// The shortest path from start to end inclusive, as coordinates.
+	// Empty if the two cells are not connected.
+	Common::Array<CellCoord> shortestPath(CellCoord start, CellCoord end) const;
+	int32 paddedIndexForCoord(CellCoord coord);
+
+private:
+	int16 indexForCoord(CellCoord coord) const;
+	CellCoord coordForIndex(int16 index) const;
+	Cell &at(CellCoord coord);
+	Common::Array<uint> buildShortestPath(uint startIndex, uint endIndex) const;
+
+	int16 _rows = 0;
+	int16 _columns = 0;
+	Common::Array<Cell> _cells;
+};
+
+// A simple maze with one player (puppy) and one enemy (Cruella). This translates the
+// padded cell indexing that scripts use to and from actual grid coordinates.
+class Maze {
+public:
+	Maze(const Common::Array<ScriptValue> &args); // newMaze_init
+	~Maze();
+
+	void solve(const Common::Array<ScriptValue> &args, ScriptValue &returnValue); // newMaze_solve
+
+private:
+	CellGrid *_grid = nullptr;
+};
+
+} // End of namespace MazeMinigame
+
+} // End of namespace MediaStation
+
+#endif
diff --git a/engines/mediastation/module.mk b/engines/mediastation/module.mk
index 928d77fe143..503cbb0de3d 100644
--- a/engines/mediastation/module.mk
+++ b/engines/mediastation/module.mk
@@ -41,6 +41,7 @@ MODULE_OBJS = \
 	minigames/dotgame.o \
 	minigames/sophie.o \
 	minigames/stalkingzazu.o \
+	minigames/maze.o \
 	profile.o
 
 # This module can be built as a plugin


Commit: dc7ddeb4bdcd7e54ad6c2778e08012fdfdf76aa2
    https://github.com/scummvm/scummvm/commit/dc7ddeb4bdcd7e54ad6c2778e08012fdfdf76aa2
Author: Nathanael Gentry (nathanael.gentrydb8 at gmail.com)
Date: 2026-07-18T20:59:49-04:00

Commit Message:
MEDIASTATION: Implement hardcoded checkers minigame for Hercules

Assisted-by: gpt-5.5 claude-sonnet-4.5

Changed paths:
  A engines/mediastation/minigames/checkers.cpp
  A engines/mediastation/minigames/checkers.h
    engines/mediastation/mediascript/codechunk.h
    engines/mediastation/mediascript/function.cpp
    engines/mediastation/mediascript/function.h
    engines/mediastation/module.mk


diff --git a/engines/mediastation/mediascript/codechunk.h b/engines/mediastation/mediascript/codechunk.h
index 5b52b148ab5..74faa7c02cc 100644
--- a/engines/mediastation/mediascript/codechunk.h
+++ b/engines/mediastation/mediascript/codechunk.h
@@ -41,7 +41,7 @@ public:
 private:
 	// This is not the number of recursive calls, it is as far is the script call stack is
 	// ever allowed to get.
-	static const uint MAX_CALL_DEPTH = 0x0f;
+	static const uint MAX_CALL_DEPTH = 0x20;
 
 	void skipNextBlock();
 
diff --git a/engines/mediastation/mediascript/function.cpp b/engines/mediastation/mediascript/function.cpp
index d9ef66a71c5..3c7ebd154a5 100644
--- a/engines/mediastation/mediascript/function.cpp
+++ b/engines/mediastation/mediascript/function.cpp
@@ -26,6 +26,7 @@
 #include "mediastation/debugchannels.h"
 #include "mediastation/mediastation.h"
 #include "mediastation/minigames/maze.h"
+#include "mediastation/minigames/checkers.h"
 
 namespace MediaStation {
 
@@ -100,6 +101,7 @@ FunctionManager::~FunctionManager() {
 	_functions.clear();
 
 	delete _maze;
+	delete _checkers;
 }
 
 bool FunctionManager::attemptToReadFromStream(Chunk &chunk, uint sectionType) {
@@ -253,6 +255,7 @@ ScriptValue FunctionManager::call(uint functionId, Common::Array<ScriptValue> &a
 		break;
 
 	case kCheckersFunction:
+		FUNCARGMIN(1);
 		script_Checkers(args, returnValue);
 		break;
 
@@ -635,10 +638,6 @@ void FunctionManager::script_EndTimedInterval(Common::Array<ScriptValue> &args,
 	returnValue.setToFloat(secondsElapsed);
 }
 
-void FunctionManager::script_Checkers(Common::Array<ScriptValue> &args, ScriptValue &returnValue) {
-	warning("STUB: %s", __func__);
-}
-
 void FunctionManager::script_Drawing(Common::Array<ScriptValue> &args, ScriptValue &returnValue) {
 	warning("STUB: %s", __func__);
 }
diff --git a/engines/mediastation/mediascript/function.h b/engines/mediastation/mediascript/function.h
index 6a6f2ff1e22..bb2568df2ec 100644
--- a/engines/mediastation/mediascript/function.h
+++ b/engines/mediastation/mediascript/function.h
@@ -58,6 +58,10 @@ namespace MazeMinigame {
 	class Maze;
 } // End of namespace MazeMinigame
 
+namespace CheckersMinigame {
+	class Checkers;
+} // End of namespace CheckersMinigame
+
 class FunctionManager : public ParameterClient {
 friend class Debugger;
 
@@ -71,6 +75,7 @@ public:
 	void deleteFunctionsForContext(uint contextId);
 
 	MazeMinigame::Maze *_maze = nullptr;
+	CheckersMinigame::Checkers *_checkers = nullptr;
 
 	uint _scriptBlockCallDepth = 0;
 
diff --git a/engines/mediastation/minigames/checkers.cpp b/engines/mediastation/minigames/checkers.cpp
new file mode 100644
index 00000000000..8fb6a4fcfdf
--- /dev/null
+++ b/engines/mediastation/minigames/checkers.cpp
@@ -0,0 +1,979 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#include "common/algorithm.h"
+#include "common/system.h"
+
+#include "mediastation/debugchannels.h"
+#include "mediastation/mediascript/function.h"
+#include "mediastation/mediastation.h"
+#include "mediastation/minigames/checkers.h"
+
+namespace MediaStation {
+
+// For exact argument count.
+#define SUBCOMMANDARGCHECK(n) \
+	if (args.size() != (n)) { \
+		warning("%s: subcommand %d: expected %d argument%s, got %d", __func__, subcommand, (n), ((n) == 1 ? "" : "s"), args.size()); \
+	}
+
+void FunctionManager::script_Checkers(Common::Array<ScriptValue> &args, ScriptValue &returnValue) {
+	const int subcommand = static_cast<int>(args[0].asFloat());
+	switch (subcommand) {
+	case CheckersMinigame::kCheckersCommandPiecesThatCanMove: {
+		// This indeed create a temporary board and then throws it away.
+		SUBCOMMANDARGCHECK(3);
+		Collection *collection = args[1].asCollection();
+		CheckersMinigame::Checkers checkers(collection);
+		if (!checkers.validateBoard()) {
+			warning("%s: Board failed validation, trying to continue", __func__);
+		}
+
+		// Not sure why the script couldn't have just provided the right side value
+		// to begin with, but we have to massage it here.
+		CheckersMinigame::Side side = (args[2].asFloat() == 0.0) ? CheckersMinigame::kBlackSide : CheckersMinigame::kWhiteSide;
+
+		Collection *result = new Collection();
+		Common::Array<CheckersMinigame::Pair> piecesThatCanMove = checkers.piecesThatCanMove(side);
+		for (const CheckersMinigame::Pair &pair : piecesThatCanMove) {
+			int linearCellIndex = pair.toLinearCellIndex();
+			ScriptValue value;
+			value.setToFloat(linearCellIndex);
+			result->push_back(value);
+		}
+
+		// The return value takes ownership of the collection.
+		returnValue.setToCollection(result);
+		break;
+	}
+
+	case CheckersMinigame::kCheckersGetValidMoves: {
+		// This indeed creates a temporary checkers board and then throws it away.
+		SUBCOMMANDARGCHECK(4);
+		Collection *collection = args[1].asCollection();
+		CheckersMinigame::Checkers checkers(collection);
+		if (!checkers.validateBoard()) {
+			warning("%s: Board failed validation, trying to continue", __func__);
+		}
+
+		int linearCellIndex = static_cast<int>(args[2].asFloat());
+		if (linearCellIndex < 0 || linearCellIndex > CheckersMinigame::TOTAL_PLAYABLE_CELLS - 1) {
+			error("%s: Linear cell index %d out of bounds", __func__, linearCellIndex);
+		}
+		bool getJumpsOnly = args[3].asBool();
+
+		Collection *result = new Collection();
+		CheckersMinigame::Pair position(linearCellIndex);
+		Common::Array<CheckersMinigame::Move> moves;
+		checkers.getValidMovesForPiece(position, moves, getJumpsOnly);
+		for (const CheckersMinigame::Move &move : moves) {
+			ScriptValue value;
+			Collection *moveCollection = move.asCollection();
+			value.setToCollection(moveCollection);
+			result->push_back(value);
+		}
+
+		// The return value takes ownership of the collection.
+		returnValue.setToCollection(result);
+		break;
+	}
+
+	case CheckersMinigame::kCheckersNewGame: {
+		SUBCOMMANDARGCHECK(4);
+		bool ok = false;
+		Collection *collection = args[1].asCollection();
+		delete g_engine->getFunctionManager()->_checkers;
+		g_engine->getFunctionManager()->_checkers = new CheckersMinigame::Checkers(collection);
+		CheckersMinigame::Checkers *checkers = g_engine->getFunctionManager()->_checkers;
+		if (!checkers->validateBoard()) {
+			warning("%s: Board failed validation, trying to continue", __func__);
+		}
+
+		// Not sure why the script couldn't have just provided the right side value
+		// to begin with, but we have to massage it here.
+		CheckersMinigame::Side side = (args[2].asFloat() == 0.0) ? CheckersMinigame::kBlackSide : CheckersMinigame::kWhiteSide;
+
+		// When playing against Hades, the chosen level seems to dictate Hades' move
+		// search depth. Known versions use the following values:
+		//  - Level 1: 1 move ahead
+		//  - Level 2: 2 moves ahead
+		//  - Level 3: 4 moves ahead
+		int moveSearchDepth = MAX(1, static_cast<int>(args[3].asFloat()));
+		if (checkers->calculateAndSortAllMovesForSide(side)) {
+			checkers->prepareForBestMoveSearch(moveSearchDepth);
+			ok = true;
+		}
+
+		returnValue.setToBool(ok);
+		break;
+	}
+
+	case CheckersMinigame::kCheckersFindBestMove: {
+		SUBCOMMANDARGCHECK(2);
+		CheckersMinigame::Checkers *checkers = g_engine->getFunctionManager()->_checkers;
+		if (checkers == nullptr) {
+			error("%s: Board is not initialized", __func__);
+		}
+
+		bool foundBestMove = false;
+		double maxSearchTimeInSeconds = args[1].asTime();
+		int maxSearchTimeInMs = MAX(100, static_cast<int>(maxSearchTimeInSeconds * 1000.0));
+		foundBestMove = checkers->searchForBestMove(maxSearchTimeInMs);
+		returnValue.setToBool(foundBestMove);
+		break;
+	}
+
+	case CheckersMinigame::kCheckersTakeMove: {
+		CheckersMinigame::Checkers *checkers = g_engine->getFunctionManager()->_checkers;
+		if (checkers == nullptr) {
+			error("%s: Board is not initialized", __func__);
+		}
+
+		CheckersMinigame::Move &bestMove = checkers->getCalculatedBestMove();
+		Collection *result = bestMove.asCollection();
+
+		// The return value takes ownership of the collection.
+		returnValue.setToCollection(result);
+		break;
+	}
+
+	case CheckersMinigame::kCheckersCommandDebugPrint: {
+		// This indeed creates a temporary checkers board and then throws it away.
+		SUBCOMMANDARGCHECK(2);
+		CheckersMinigame::Checkers checkers(args[1].asCollection());
+		checkers.printToDebug();
+		break;
+	}
+
+	case CheckersMinigame::kCheckersCommandValidateBoard: {
+		// This looks like debug machinery left in the release version, but
+		// scripts actually do call it, so it is reimplemented here.
+		// This indeed creates a temporary checkers board and then throws it away.
+		SUBCOMMANDARGCHECK(2);
+		CheckersMinigame::Checkers checkers(args[1].asCollection());
+		returnValue.setToBool(checkers.validateBoard());
+		break;
+	}
+
+	default:
+		warning("%s: Got unimplemented checkers subcommand %d", __func__, subcommand);
+		break;
+	}
+}
+
+namespace CheckersMinigame {
+
+constexpr SideConstants BLACK_INFO = {
+	Side::kWhiteSide,
+	0,
+	{
+		{-1,  1}, { 1,  1},	// Forward (toward row 7).
+		{-1, -1}, { 1, -1}	// Backward.
+	}
+};
+
+constexpr SideConstants WHITE_INFO = {
+	Side::kBlackSide,
+	7,
+	{
+		{-1, -1}, { 1, -1},	// Forward (toward row 0).
+		{-1,  1}, { 1,  1}	// Backward.
+	}
+};
+
+// The two playable corner-adjacent squares on each side's home row. Keeping an
+// uncrowned man on one of these denies the opponent an easy promotion.
+static constexpr Pair BACK_RANK_DEFENDER_CELLS[] = {
+	{1, 0}, {5, 0},  // Black's home row.
+	{2, 7}, {6, 7}   // White's home row.
+};
+
+const int kLossScore = -29999;
+const int kWinScore = 29999;
+
+Checkers::Checkers(Collection *collection) {
+	_board.resize(TOTAL_CELLS);
+
+	if (collection == nullptr) {
+		error("%s: Collection is null", __func__);
+	} else if (collection->size() < TOTAL_PLAYABLE_CELLS) {
+		error("%s: Source collection doesn't cover all playable cells", __func__);
+	}
+
+	Common::Array<int> cellValues(TOTAL_PLAYABLE_CELLS);
+	for (uint i = 0; i < TOTAL_PLAYABLE_CELLS; i++) {
+		cellValues[i] = static_cast<int>(collection->operator[](i).asFloat());
+	}
+	setBoard(cellValues);
+}
+
+void Checkers::setBoard(const Common::Array<int> &cellValues) {
+	// Parse the cell values from the script into an actual board.
+	uint padding = 1;
+	uint linearCellIndex = 0;
+	for (uint row = 0; row < ROW_COUNT; row++) {
+		for (uint col = 0; col < PLAYABLE_CELLS_PER_ROW; col++) {
+			Cell &cell = _board.cellAt(Pair(padding + col * 2, row));
+
+			int cellValueToParse = cellValues[linearCellIndex];
+			if (cellValueToParse == 0) {
+				cell.isKing = false;
+				cell.side = kUnoccupiedBySide;
+				cell.pieceId = 0;
+			} else {
+				if (cellValueToParse < 101) {
+					cell.isKing = false;
+				} else {
+					cell.isKing = true;
+					cellValueToParse -= 100;
+				}
+
+				cell.side = cellValueToParse < 13 ? kBlackSide : kWhiteSide;
+				cell.pieceId = cellValueToParse;
+			}
+
+			linearCellIndex++;
+		}
+
+		padding = (padding == 0);
+	}
+}
+
+Cell &Board::cellAt(const Pair &position) {
+	assert(position.isWithinBoard());
+	return operator[](position.x * COLUMN_COUNT + position.y);
+}
+
+const Cell &Board::cellAt(const Pair &position) const {
+	assert(position.isWithinBoard());
+	return operator[](position.x * COLUMN_COUNT + position.y);
+}
+
+Cell &Board::cellAt(uint linearIndex) {
+	return operator[](linearIndex);
+}
+
+void Checkers::updateMovesThatKing(const Board &board, Common::Array<Move> &moves) {
+	if (moves.empty()) {
+		return;
+	}
+
+	const Cell &firstMovePiece = board.cellAt(moves[0].from);
+	const int promotionRow = (firstMovePiece.side == kBlackSide) ? (ROW_COUNT - 1) : 0;
+
+	for (Move &move : moves) {
+		const Cell &movingPiece = board.cellAt(move.from);
+		if (!movingPiece.isKing && move.to.y == promotionRow) {
+			move.willBecomeKing = true;
+		}
+	}
+}
+
+bool Checkers::validateBoard() const {
+	bool valid = true;
+
+	int seen[25] = {};
+	seen[0] = -1;
+
+	Pair pos;
+	for (pos.y = 0; pos.y < ROW_COUNT; pos.y++) {
+		const int startX = (pos.y & 1) ? 0 : 1;
+		for (pos.x = startX; pos.x < COLUMN_COUNT; pos.x += 2) {
+			const Cell &cell = _board.cellAt(pos);
+			if (cell.pieceId == 0) {
+				continue;
+			}
+
+			if (cell.pieceId < 1 || cell.pieceId > 24) {
+				valid = false;
+				continue;
+			}
+
+			if (seen[cell.pieceId] != 0) {
+				valid = false;
+			} else {
+				seen[cell.pieceId] = pos.y * 8 + pos.x + 1;
+			}
+
+			if (cell.side == kBlackSide && pos.y == 7 && !cell.isKing) {
+				warning("%s: Piece %d should be kinged", __func__, cell.pieceId);
+				valid = false;
+			}
+
+			if (cell.side == kWhiteSide && pos.y == 0 && !cell.isKing) {
+				warning("%s: Piece %d should be kinged", __func__, cell.pieceId);
+				valid = false;
+			}
+		}
+	}
+
+	return valid;
+}
+
+void Checkers::printToDebug() const {
+	bool filledCellStartsAtEvenX = false;
+
+	for (int y = 0; y < ROW_COUNT; y++) {
+		Common::String border;
+		for (int x = 0; x < COLUMN_COUNT; x++) {
+			border += "+------";
+		}
+		border += "+";
+		debugC(5, kDebugMinigame, "%s", border.c_str());
+
+		Common::String row;
+		for (int x = 0; x < COLUMN_COUNT; x++) {
+			const Cell &cell = _board.cellAt(Pair(x, y));
+			Common::String cellText;
+
+			if (cell.side == kUnoccupiedBySide) {
+				cellText = (filledCellStartsAtEvenX == ((x % 2) == 0)) ? "####" : "    ";
+			} else {
+				const char kingChar = cell.isKing ? 'k' : ' ';
+				const char sideChar = (cell.side == kBlackSide) ? 'B' : 'W';
+				cellText = Common::String::format("%c%2d%c", sideChar, ((cell.pieceId - 1) % 12) + 1, kingChar);
+			}
+
+			row += Common::String::format("|  %s ", cellText.c_str());
+		}
+		row += "|";
+		debugC(5, kDebugMinigame, "%s", row.c_str());
+
+		filledCellStartsAtEvenX = !filledCellStartsAtEvenX;
+	}
+
+	Common::String border;
+	for (int x = 0; x < COLUMN_COUNT; x++) {
+		border += "+------";
+	}
+	border += "+\n";
+	debugC(5, kDebugMinigame, "%s", border.c_str());
+}
+
+Common::Array<Pair> Checkers::piecesThatCanMove(Side side) {
+	// Regenerate the legal moves list for this side.
+	Common::Array<Pair> moveablePieces;
+	Common::Array<Move> moves;
+	bool canDoAtLeastOneJump = checkForJumps(_board, side, nullptr, moves);
+	if (!canDoAtLeastOneJump) {
+		checkForSlides(_board, side, nullptr, moves);
+	}
+
+	bool movable[COLUMN_COUNT][ROW_COUNT] = {};
+	for (const Move &move : moves) {
+		movable[move.from.x][move.from.y] = true;
+	}
+
+	for (int y = 0; y < ROW_COUNT; y++) {
+		for (int x = 0; x < COLUMN_COUNT; x++) {
+			if (movable[x][y]) {
+				moveablePieces.push_back(Pair(x, y));
+			}
+		}
+	}
+
+	return moveablePieces;
+}
+
+void Checkers::getValidMovesForPiece(
+	const Pair &position, Common::Array<Move> &moves, bool getJumpsOnly) {
+	moves.clear();
+
+	const Cell &cell = _board.cellAt(position);
+	if (cell.isOccupied()) {
+		const bool canDoAtLeastOneJump = checkForJumps(_board, cell.side, &position, moves);
+		if (!getJumpsOnly) {
+			if (!canDoAtLeastOneJump) {
+				checkForSlides(_board, cell.side, &position, moves);
+			}
+		}
+
+		updateMovesThatKing(_board, moves);
+	}
+}
+
+bool Checkers::checkForJumps(const Board &board, Side mySide, const Pair *checkPosition, Common::Array<Move> &moves) {
+	const SideConstants &constants = constantsForSide(mySide);
+
+	// See if the current side can do any jumps (captures).
+	bool anyValidJumps = false;
+	Pair min(0, 0);
+	Pair max(COLUMN_COUNT - 1, ROW_COUNT - 1);
+	if (checkPosition != nullptr) {
+		min.x = max.x = checkPosition->x;
+		min.y = max.y = checkPosition->y;
+	}
+
+	for (int y = min.y; y <= max.y; y++) {
+		for (int x = min.x; x <= max.x; x++) {
+			// Only look at our pieces.
+			Pair from(x, y);
+			const Cell &piece = board.cellAt(from);
+			if (piece.side != mySide) {
+				continue;
+			}
+
+			// Kings can move diagonally backward and forward (4 total directions),
+			// but men can only move diagonally forward (2 total directions).
+			int totalValidDirections = piece.isKing ? 4 : 2;
+			for (int i = 0; i < totalValidDirections; i++) {
+				Pair capture = from + constants.validDirections[i];
+				Pair landing = capture + constants.validDirections[i];
+
+				// Make sure we will still land inside the board.
+				if (landing.isWithinBoard()) {
+					// We can jump iff the adjacent cell is occupied by an opponent...
+					if (board.cellAt(capture).side != constants.opponent) {
+						continue;
+					}
+
+					// ...and the cell that we'll land on is empty.
+					if (board.cellAt(landing).isOccupied()) {
+						continue;
+					}
+
+					addMove(moves, from, landing, &capture, false);
+					anyValidJumps = true;
+				}
+			}
+		}
+	}
+
+	return anyValidJumps;
+}
+
+bool Checkers::checkForSlides(const Board &board, Side side, const Pair *checkPosition, Common::Array<Move> &moves) {
+	const SideConstants &constants = constantsForSide(side);
+
+	// See if the current side can do any slides (moves without a capture).
+	bool anyValidSlides = false;
+	Pair min(0, 0);
+	Pair max(COLUMN_COUNT - 1, ROW_COUNT - 1);
+	if (checkPosition != nullptr) {
+		min.x = max.x = checkPosition->x;
+		min.y = max.y = checkPosition->y;
+	}
+
+	for (int y = min.y; y <= max.y; y++) {
+		for (int x = min.x; x <= max.x; x++) {
+			// Only look at our pieces.
+			Pair from(x, y);
+			const Cell &piece = board.cellAt(from);
+			if (piece.side != side) {
+				continue;
+			}
+
+			// Kings can move diagonally backward and forward (4 total directions),
+			// but men can only move diagonally forward (2 total directions).
+			int dirCount = piece.isKing ? 4 : 2;
+			for (int i = 0; i < dirCount; i++) {
+				Pair to = from + constants.validDirections[i];
+
+				if (to.isWithinBoard()) {
+					if (!board.cellAt(to).isOccupied()) {
+						addMove(moves, from, to, nullptr, false);
+						anyValidSlides = true;
+					}
+				}
+			}
+		}
+	}
+
+	return anyValidSlides;
+}
+
+void Checkers::addMove(Common::Array<Move> &moves, const Pair &from, const Pair &to, const Pair *captured, bool becameKing) {
+	Move move;
+	move.from = from;
+	move.to = to;
+
+	if (captured != nullptr) {
+		move.willCapture = true;
+		move.capture = *captured;
+	} else {
+		move.capture = {-1, -1};
+	}
+	moves.push_back(move);
+}
+
+void Checkers::makeMove(Board &board, const Move &move) {
+	Cell &fromCell = board.cellAt(move.from);
+	Cell &toCell = board.cellAt(move.to);
+	// The piece moves, and its original position is now unoccupied.
+	toCell = fromCell;
+	fromCell = Cell();
+
+	if (move.willBecomeKing) {
+		toCell.isKing = true;
+	}
+
+	if (move.willCapture) {
+		// The cell that we jumped over is now unoccupied,
+		// as the piece inside it was captured.
+		board.cellAt(move.capture) = Cell();
+	}
+
+	for (const Move &nextJump : move.nextJumps) {
+		makeMove(board, nextJump);
+	}
+}
+
+PieceCounts Checkers::getPieceCounts(const Board &board) {
+	PieceCounts counts;
+	Pair pos;
+	for (pos.y = 0; pos.y < ROW_COUNT; pos.y++) {
+		for (pos.x = 0; pos.x < COLUMN_COUNT; pos.x++) {
+			const Cell &cell = board.cellAt(pos);
+			if (cell.side == kBlackSide) {
+				cell.isKing ? counts.totalBlackKings++ : counts.totalBlackMen++;
+
+			} else if (cell.side == kWhiteSide) {
+				cell.isKing ? counts.totalWhiteKings++ : counts.totalWhiteMen++;
+			}
+		}
+	}
+	return counts;
+}
+
+int Checkers::getTotalBoardScoreForSide(const Board &board, Side ourSide) {
+	PieceCounts counts = getPieceCounts(board);
+	uint blackMaterialScore = (counts.totalBlackKings * 200) + (counts.totalBlackMen * 100);
+	uint whiteMaterialScore = (counts.totalWhiteKings * 200) + (counts.totalWhiteMen * 100);
+
+	Side opposingSide = kUnoccupiedBySide;
+	int homeRow = 0;
+	int score = 0;
+	switch (ourSide) {
+	case kBlackSide:
+		opposingSide = kWhiteSide;
+		score = blackMaterialScore - whiteMaterialScore;
+		break;
+
+	case kWhiteSide:
+		opposingSide = kBlackSide;
+		homeRow = 7;
+		score = whiteMaterialScore - blackMaterialScore;
+		break;
+
+	default:
+		error("%s: Got bad side %d", __func__, static_cast<uint>(ourSide));
+	}
+
+	if (!canMove(board, ourSide)) {
+		// We can't move, so we lost.
+		return kLossScore;
+
+	} else if (!canMove(board, opposingSide)) {
+		// Our opponent can't move, so we won.
+		return kWinScore;
+	}
+
+	// Award a small bonus for occupying one of the back-rank defender squares.
+	// Keeping an uncrowned piece here discourages the opponent from obtaining
+	// an easy promotion, so only men (not kings) get the bonus.
+	for (const Pair &defenderCell : BACK_RANK_DEFENDER_CELLS) {
+		const Cell &cell = board.cellAt(defenderCell);
+		if (cell.isMan()) {
+			score += (cell.side == ourSide) ? 7 : -7;
+		}
+	}
+
+	// Reward uncrowned pieces for advancing from their home row.
+	// This encourages men to make progress toward promotion.
+	Pair pos;
+	for (pos.y = 0; pos.y < ROW_COUNT; pos.y++) {
+		for (pos.x = 0; pos.x < COLUMN_COUNT; pos.x++) {
+			const Cell &cell = board.cellAt(pos);
+			if (cell.side == ourSide && !cell.isKing) {
+				score += ABS(homeRow - pos.y);
+			}
+		}
+	}
+
+	return score + getWeightedSquareScoreForSide(board, ourSide) - getWeightedSquareScoreForSide(board, opposingSide);
+}
+
+void Checkers::calculatePointsForMoves(const Board &board, Common::Array<Move> &moves) {
+	if (moves.empty()) {
+		return;
+	}
+
+	// The list of current moves should only contain moves from a single
+	// side at one time. Thus, we can determine this side by looking at the
+	// side of the first move.
+	const Side side = board.cellAt(moves[0].from).side;
+
+	for (Move &move : moves) {
+		Board updatedBoard = board;
+		makeMove(updatedBoard, move);
+		move.score = getTotalBoardScoreForSide(updatedBoard, side);
+	}
+}
+
+int Checkers::getWeightedSquareScoreForSide(const Board &board, Side side) {
+	PieceCounts counts = getPieceCounts(board);
+	const int ourKings = (side == kBlackSide) ? counts.totalBlackKings : counts.totalWhiteKings;
+	const int opponentKings = (side == kBlackSide) ? counts.totalWhiteKings : counts.totalBlackKings;
+	const bool behindInKings = ourKings < opponentKings;
+
+	int score = 0;
+	for (int linearCellIndex = 0; linearCellIndex < TOTAL_PLAYABLE_CELLS; linearCellIndex++) {
+		// Only look at our own cells.
+		const Cell &cell = board.cellAt(linearCellIndex);
+		if (cell.side != side) {
+			continue;
+		}
+
+		CellClass classification = CELL_CLASSIFICATION[linearCellIndex];
+		switch (classification) {
+		case kCellCorner:
+			// Corners are relatively safe because they can only be
+			// attacked from one direction.
+			if (cell.isKing && behindInKings) {
+				// If you’re behind in king count, keeping your kings
+				// in corners makes them harder to trap.
+				score += 10;
+			} else {
+				score += 2;
+			}
+			break;
+
+		case kCellNearCorner:
+			// These squares are adjacent to the corners and can become traps. A king
+			// here has fewer escape routes than one actually in the corner, so this
+			// is a much less ideal spot to be in than actually being in a corner.
+			if (cell.isKing && behindInKings) {
+				score -= 8;
+			}
+			break;
+
+		case kCellCenter:
+			// The four central playable squares can provide the greatest mobility and control.
+			score += 4;
+			break;
+
+		case kCellOuterCenter:
+			// Slightly discourage occupying these in favor of the true center.
+			score -= 1;
+			break;
+
+		case kCellNormal:
+			break;
+		}
+	}
+
+	return score;
+}
+
+bool Checkers::canMove(const Board &board, Side side) {
+	// Regenerate the legal moves list for this side.
+	Common::Array<Move> moves;
+	bool canMove = checkForJumps(board, side, nullptr, moves);
+	if (!canMove) {
+		canMove = checkForSlides(board, side, nullptr, moves);
+	}
+
+	return canMove;
+}
+
+int Checkers::alphaBetaEvaluate(
+	const Board &board, Move &parentMove, Side sideToSolve, Side sideToMove, int alpha, int beta, int currentSearchDepth, uint32 searchDeadlineInMs) {
+	// The original used an iterative search with an explicit state machine (and manually managed stack),
+	// but that was judged needlessly complex to reimplement here. For modern hardware, just do it recursively.
+
+	Common::Array<Move> moves;
+	bool searchTimeExpired = static_cast<int32>(g_system->getMillis() - searchDeadlineInMs) >= 0;
+	bool forceEndSearch = searchTimeExpired || currentSearchDepth > _maxSearchDepth;
+	if (forceEndSearch) {
+		// Just recommend the best move we found thus far.
+		return getTotalBoardScoreForSide(board, sideToSolve);
+	}
+
+	// If we just jumped but can jump again from our new position, we must do so.
+	// If we both captured and became a king on the same turn, we are already
+	// at the last row on the board, so it is not possible to jump again from this position.
+	bool canMaybeJumpAgain = parentMove.willCapture && !parentMove.willBecomeKing;
+	bool requireContinuationJump = false;
+	if (canMaybeJumpAgain) {
+		const Side continuingSide = board.cellAt(parentMove.to).side;
+		requireContinuationJump = checkForJumps(board, continuingSide, &parentMove.to, moves);
+		if (requireContinuationJump) {
+			// We must jump again, so ensure the turn remains ours.
+			sideToMove = continuingSide;
+		}
+	}
+
+	Side nextSideToMove = kUnoccupiedBySide;
+	int nextSearchDepth = currentSearchDepth;
+	if (requireContinuationJump) {
+		nextSideToMove = sideToMove;
+		updateMovesThatKing(board, moves);
+		calculatePointsForMoves(board, moves);
+		sortMovesByScore(moves);
+		// We do not treat continuation jumps as separate moves in terms of
+		// the search depth limit.
+	} else {
+		nextSideToMove = constantsForSide(sideToMove).opponent;
+		calculateAndSortMovesForSide(board, sideToMove, moves);
+		nextSearchDepth++;
+	}
+
+	bool searchIsFinished = moves.empty();
+	if (searchIsFinished) {
+		return getTotalBoardScoreForSide(board, sideToSolve);
+	}
+
+	int bestScore = 0;
+	if (sideToMove == sideToSolve) {
+		// It's our turn (the side we're solving for/maximizing player).
+		// Evaluate every legal move and keep the highest score found thus far.
+		// Thus, we start with the lowest possible score (a loss).
+		bestScore = kLossScore;
+		for (Move &move : moves) {
+			Board updatedBoard = board;
+			makeMove(updatedBoard, move);
+
+			const int score = alphaBetaEvaluate(updatedBoard, move, sideToSolve, nextSideToMove, alpha, beta, nextSearchDepth, searchDeadlineInMs);
+			move.score = score;
+			if (score > bestScore) {
+				// This is the best move we've seen so far, so record it.
+				bestScore = score;
+				if (requireContinuationJump) {
+					parentMove.nextJumps.clear();
+					parentMove.nextJumps.push_back(move);
+				}
+			}
+
+			// Raise the lower bound on the score this position can achieve.
+			alpha = MAX(alpha, bestScore);
+			if (alpha >= beta) {
+				// No remaining move can affect the final result.
+				// Stop searching this branch.
+				break;
+			}
+		}
+
+	} else {
+		// It's our opponent's turn (minimizing player). Evaluate every legal move and
+		// assume they choose the move that give us the lowest score.
+		bestScore = kWinScore;
+		for (Move &move : moves) {
+			Board updatedBoard = board;
+			makeMove(updatedBoard, move);
+			const int score = alphaBetaEvaluate(updatedBoard, move, sideToSolve, nextSideToMove, alpha, beta, nextSearchDepth, searchDeadlineInMs);
+			move.score = score;
+
+			if (score < bestScore) {
+				// Record the worst score (from our perspective) seen so far.
+				bestScore = score;
+				if (requireContinuationJump) {
+					parentMove.nextJumps.clear();
+					parentMove.nextJumps.push_back(move);
+				}
+			}
+
+			// Lower the upper bound on the score this position can achieve.
+			beta = MIN(beta, bestScore);
+			if (alpha >= beta) {
+				// No remaining move can affect the final result, so stop searching
+				// this branch early.
+				break;
+			}
+		}
+	}
+
+	sortMovesByScore(moves);
+	return bestScore;
+}
+
+void Checkers::prepareForBestMoveSearch(int moveSearchDepth) {
+	_maxSearchDepth = MAX(1, moveSearchDepth);
+
+	if (_moves.empty()) {
+		_sideToMove = kUnoccupiedBySide;
+		_bestMove = Move();
+	} else {
+		// The list of valid moves only contains moves from one side or the other
+		// at any given time. So we can determine the current side just by looking
+		// at the first move.
+		_sideToMove = _board.cellAt(_moves[0].from).side;
+		updateMovesThatKing(_board, _moves);
+		calculatePointsForMoves(_board, _moves);
+		sortMovesByScore(_moves);
+		_bestMove = _moves[0];
+	}
+}
+
+bool Checkers::searchForBestMove(int maxSearchTimeInMs) {
+	// The original had this timing logic to make sure finding the best move
+	// didn't take too much time. This should not be a concern on modern hardware,
+	// but we will include it for accuracy to the original.
+	maxSearchTimeInMs = MAX(1, maxSearchTimeInMs);
+	const uint32 searchDeadlineInMs = g_system->getMillis() + maxSearchTimeInMs;
+
+	if (_sideToMove == kUnoccupiedBySide) {
+		if (_moves.empty()) {
+			// TODO: Not sure why white is chosen here as black usually goes first?
+			_sideToMove = kWhiteSide;
+		} else {
+			_sideToMove = _board.cellAt(_moves[0].from).side;
+		}
+	}
+
+	bool canMove = calculateAndSortAllMovesForSide(_sideToMove) && !_moves.empty();
+	if (!canMove) {
+		return false;
+	}
+
+	// Search each legal move from the current position. As better moves are
+	// found, raise alpha so subsequent searches can prune more aggressively.
+	int alpha = kLossScore;
+	const int beta = kWinScore;
+	for (Move &move : _moves) {
+		Board speculativeBoard = _board;
+		makeMove(speculativeBoard, move);
+		move.score = alphaBetaEvaluate(
+			speculativeBoard, move, _sideToMove,
+			constantsForSide(_sideToMove).opponent,
+			alpha, beta, 0, searchDeadlineInMs);
+
+		// Track the best score from the current position so later searches can prune sooner.
+		alpha = MAX(alpha, move.score);
+	}
+
+	// Determine if there's more than one best move.
+	sortMovesByScore(_moves);
+	const int bestScore = _moves[0].score;
+	uint tiedMoves = 0;
+	for (const Move &move : _moves) {
+		if (move.score == bestScore) {
+			tiedMoves++;
+		} else {
+			break;
+		}
+	}
+	// If there is more than one best move, choose one randomly.
+	uint bestMoveIndex = 0;
+	if (tiedMoves > 1) {
+		bestMoveIndex = g_engine->_randomSource.getRandomNumber(tiedMoves - 1);
+	}
+	_bestMove = _moves[bestMoveIndex];
+
+	return true;
+}
+
+Move &Checkers::getCalculatedBestMove() {
+	return _bestMove;
+}
+
+Collection *Move::asCollection() const {
+	// Serialize the move back out to a collection so scripts can use it.
+	Collection *collection = new Collection();
+	ScriptValue value;
+	value.setToFloat(from.toLinearCellIndex());
+	collection->push_back(value);
+
+	const Move *currentMove = this;
+	while (currentMove != nullptr) {
+		value.setToFloat(currentMove->to.toLinearCellIndex());
+		collection->push_back(value);
+
+		int captured = -1;
+		if (currentMove->willCapture == true) {
+			captured = currentMove->capture.toLinearCellIndex();
+		}
+
+		if (currentMove->willBecomeKing) {
+			captured += 100;
+		}
+
+		value.setToFloat(captured);
+		collection->push_back(value);
+
+		currentMove = currentMove->nextJumps.empty() ? nullptr : &currentMove->nextJumps[0];
+	}
+
+	// The caller takes ownership of the collection.
+	return collection;
+}
+
+bool Checkers::calculateAndSortMovesForSide(const Board &board, Side side, Common::Array<Move> &moves) {
+	// Regenerate the legal moves list for this side.
+	moves.clear();
+	bool canMove = true;
+	bool canDoAtLeastOneJump = checkForJumps(board, side, nullptr, moves);
+	if (!canDoAtLeastOneJump) {
+		canMove = checkForSlides(board, side, nullptr, moves);
+	}
+
+	if (canMove) {
+		updateMovesThatKing(board, moves);
+		calculatePointsForMoves(board, moves);
+		sortMovesByScore(moves);
+	}
+
+	return canMove;
+}
+
+bool Checkers::calculateAndSortAllMovesForSide(Side side) {
+	return calculateAndSortMovesForSide(_board, side, _moves);
+}
+
+void Checkers::sortMovesByScore(Common::Array<Move> &moves) {
+	// We can't easily use a Common::SortedArray here because the sort key (score)
+	// is mutated after the elements are in the array. So we will explicitly sort
+	// at the right times to ensure this invariant is respected.
+	Common::sort(moves.begin(), moves.end(), MoveScoreGreater());
+}
+
+const SideConstants &Checkers::constantsForSide(Side side) const {
+	switch (side) {
+	case kBlackSide:
+		return BLACK_INFO;
+
+	case kWhiteSide:
+		return WHITE_INFO;
+
+	default:
+		error("%s: Got bad side %d", __func__, static_cast<uint>(side));
+	}
+}
+
+Pair::Pair(int linearCellIndex) {
+	y = linearCellIndex / 4;
+	x = (linearCellIndex % 4) * 2 + ((y + 1) & 1);
+}
+
+int Pair::toLinearCellIndex() const {
+	return y * 4 + (x >> 1);
+}
+
+bool Pair::isWithinBoard() const {
+	return (x >= 0 && x < COLUMN_COUNT) && (y >= 0 && y < ROW_COUNT);
+}
+
+Pair Pair::operator+(const Pair &other) const {
+	return Pair(x + other.x, y + other.y);
+}
+
+} // End of namespace CheckersMinigame
+
+} // End of namespace MediaStation
diff --git a/engines/mediastation/minigames/checkers.h b/engines/mediastation/minigames/checkers.h
new file mode 100644
index 00000000000..5b812196aae
--- /dev/null
+++ b/engines/mediastation/minigames/checkers.h
@@ -0,0 +1,192 @@
+/* ScummVM - Graphic Adventure Engine
+ *
+ * ScummVM is the legal property of its developers, whose names
+ * are too numerous to list here. Please refer to the COPYRIGHT
+ * file distributed with this source distribution.
+ *
+ * This program is free software: you can redistribute it and/or modify
+ * it under the terms of the GNU General Public License as published by
+ * the Free Software Foundation, either version 3 of the License, or
+ * (at your option) any later version.
+ *
+ * This program is distributed in the hope that it will be useful,
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+ * GNU General Public License for more details.
+ *
+ * You should have received a copy of the GNU General Public License
+ * along with this program.  If not, see <http://www.gnu.org/licenses/>.
+ *
+ */
+
+#ifndef MEDIASTATION_MINIGAMES_CHECKERS_H
+#define MEDIASTATION_MINIGAMES_CHECKERS_H
+
+#include "common/array.h"
+
+#include "mediastation/actor.h"
+#include "mediastation/mediascript/collection.h"
+
+namespace MediaStation {
+
+namespace CheckersMinigame {
+
+const int COLUMN_COUNT = 8;
+const int ROW_COUNT = 8;
+const int PLAYABLE_CELLS_PER_ROW = 4;
+const int TOTAL_CELLS = COLUMN_COUNT * ROW_COUNT;
+const int TOTAL_PLAYABLE_CELLS = ROW_COUNT * PLAYABLE_CELLS_PER_ROW;
+
+enum Side {
+	kUnoccupiedBySide = 0,
+	// "Black" is actually red in the game.
+	// Hades always plays black.
+	kBlackSide = 1,
+	kWhiteSide = 2
+};
+
+enum CellClass {
+	kCellNormal,
+	kCellCorner,
+	kCellNearCorner,
+	kCellOuterCenter,
+	kCellCenter
+};
+
+// Classification used by the positional evaluation score heuristic.
+// Indexed by the standard linear playable-cell numbering.
+static constexpr CellClass CELL_CLASSIFICATION[TOTAL_PLAYABLE_CELLS] = {
+	kCellCorner,        kCellNearCorner,   kCellNearCorner,   kCellNormal,
+	kCellCorner,        kCellNormal,       kCellNormal,       kCellNormal,
+	kCellNormal,        kCellNormal,       kCellNormal,       kCellOuterCenter,
+	kCellOuterCenter,   kCellCenter,       kCellCenter,       kCellNormal,
+	kCellNormal,        kCellCenter,       kCellCenter,       kCellOuterCenter,
+	kCellOuterCenter,   kCellNormal,       kCellNormal,       kCellNormal,
+	kCellNormal,        kCellNormal,       kCellNormal,       kCellCorner,
+	kCellNormal,        kCellNearCorner,   kCellNearCorner,   kCellCorner
+};
+
+struct Cell {
+	// Scripts use pieceId to track which piece image to show where.
+	int pieceId = 0;
+	Side side = kUnoccupiedBySide;
+	bool isKing = false;
+
+	bool isOccupied() const { return side != kUnoccupiedBySide; }
+	bool isMan() const { return isOccupied() && !isKing; }
+};
+
+// The coordinates of a particular cell on the checkers board.
+struct Pair {
+	int x = 0;
+	int y = 0;
+
+	constexpr Pair() = default;
+	constexpr Pair(int x_, int y_) : x(x_), y(y_) {}
+
+	Pair(int linearCellIndex); // notationToPair
+	int toLinearCellIndex() const; // pairToNotation
+
+	bool isWithinBoard() const;
+	Pair operator+(const Pair &other) const;
+};
+
+struct SideConstants {
+	Side opponent;
+	int homeRow;
+	CheckersMinigame::Pair validDirections[4];
+};
+
+struct PieceCounts {
+	int totalBlackMen = 0;
+	int totalBlackKings = 0;
+	int totalWhiteMen = 0;
+	int totalWhiteKings = 0;
+};
+
+struct Move {
+	Pair from;
+	Pair to;
+	Pair capture;
+	bool willCapture = false;
+	bool willBecomeKing = false;
+	int score = 0;
+	Common::Array<Move> nextJumps;
+
+	Collection *asCollection() const; // moveToCollection
+};
+
+enum CheckersCommand {
+	kCheckersCommandPiecesThatCanMove = 1,
+	kCheckersGetValidMoves = 2,
+	kCheckersNewGame = 3,
+	kCheckersFindBestMove = 4,
+	kCheckersTakeMove = 5,
+	kCheckersCommandDebugPrint = -2,
+	kCheckersCommandValidateBoard = -1
+};
+
+// The full 8x8 checkers grid. Although only the dark squares are ever playable,
+// every square is stored so that a Pair's (x, y) coordinates map directly onto
+// the backing storage.
+class Board : public Common::Array<Cell> {
+public:
+	Cell &cellAt(const Pair &position);
+	const Cell &cellAt(const Pair &position) const;
+	Cell &cellAt(uint linearIndex);
+};
+
+// A simple checkers engine that uses minimax with alpha/beta pruning.
+class Checkers {
+public:
+	Checkers(Collection *collection); // checkers_initBoard
+
+	void setBoard(const Common::Array<int> &squares);
+	Common::Array<CheckersMinigame::Pair> piecesThatCanMove(Side side);
+	void getValidMovesForPiece(
+		const Pair &position, Common::Array<Move> &moves, bool capturesOnly); // movesOfPiece
+	bool calculateAndSortAllMovesForSide(Side side);
+	void prepareForBestMoveSearch(int depth);
+	bool searchForBestMove(int maxSearchTimeInMs);
+	Move &getCalculatedBestMove(); // bestMoveCalculated
+	bool validateBoard() const;
+	void printToDebug() const;
+
+private:
+	Board _board;
+	Common::Array<Move> _moves;
+	int _maxSearchDepth = 1;
+	Side _sideToMove = kUnoccupiedBySide;
+	Move _bestMove;
+
+	const SideConstants &constantsForSide(Side side) const;
+	bool checkForJumps(const Board &board, Side side, const Pair *checkPosition, Common::Array<Move> &moves); // checkForJumps
+	bool checkForSlides(const Board &board, Side side, const Pair *checkPosition, Common::Array<Move> &moves);
+	void updateMovesThatKing(const Board &board, Common::Array<Move> &moves);
+	bool canMove(const Board &board, Side side);
+	void addMove(Common::Array<Move> &moves, const Pair &start, const Pair &end, const Pair *captured, bool becameKing);
+	void makeMove(Board &board, const Move &move);
+
+	bool calculateAndSortMovesForSide(const Board &board, Side side, Common::Array<Move> &moves);
+	void calculatePointsForMoves(const Board &board, Common::Array<Move> &moves); // calculateMovePoints
+	int alphaBetaEvaluate(const Board &board, Move &parentMove, Side maximizingSide, Side sideToMove, int alpha, int beta, int ply, uint32 deadline);
+
+	// This is the score that the minimax is optimizing.
+	int getTotalBoardScoreForSide(const Board &board, Side side);
+	int getWeightedSquareScoreForSide(const Board &board, Side side);
+	PieceCounts getPieceCounts(const Board &board);
+	void sortMovesByScore(Common::Array<Move> &moves);
+};
+
+// Functor to allow sorting moves by their score.
+struct MoveScoreGreater {
+	bool operator()(const CheckersMinigame::Move &left, const CheckersMinigame::Move &right) const {
+		return left.score > right.score;
+	}
+};
+
+} // End of namespace CheckersMinigame
+
+} // End of namespace MediaStation
+
+#endif
diff --git a/engines/mediastation/module.mk b/engines/mediastation/module.mk
index 503cbb0de3d..4297c51c209 100644
--- a/engines/mediastation/module.mk
+++ b/engines/mediastation/module.mk
@@ -38,6 +38,7 @@ MODULE_OBJS = \
 	mediascript/scriptvalue.o \
 	mediastation.o \
 	metaengine.o \
+	minigames/checkers.o \
 	minigames/dotgame.o \
 	minigames/sophie.o \
 	minigames/stalkingzazu.o \




More information about the Scummvm-git-logs mailing list