Hi, everyone! 👋
✨So far, we've brought our Hydra 🐉 to life with animations and movement mechanics, making it feel more dynamic and responsive. Now, it's time to take the game a step further! 🎮
In this part, we will add a background 🌿 to create a more immersive environment and introduce enemies 👾 to make the gameplay more engaging. We'll implement enemy animations 🎭, spawn them in random locations 🎲, and handle interactions between the Hydra and enemies ⚔️. This will lay the foundation for future mechanics.
To achieve this, we're expanding our game development on MAUI by layering different visual elements and managing character interactions seamlessly. Let’s dive in! 🚀

The background is a static image positioned within the same row as the SKCanvasView (HydraCanvas) inside a grid, ensuring it seamlessly integrates with the game environment.

To add enemies, I first added another SKCanvasView (SpiderCanvas) above the existing SKCanvasView (HydraCanvas). This allows us to render the canvas with spiders on a lower layer than the Hydra, ensuring proper depth in our scene.
<Grid>
<Image x:Name="BackGround"
Source="{x:Static helpers:Constants+Images.GrassBackGround}"
Aspect="AspectFill" />
<controls:SKCanvasView x:Name="SpiderCanvas"/>
<controls:SKCanvasView x:Name="HydraCanvas">
<controls:SKCanvasView.GestureRecognizers>
<TapGestureRecognizer Tapped="HydraCanvasTapped" />
</controls:SKCanvasView.GestureRecognizers>
</controls:SKCanvasView>
</Grid>
For the enemy sprites, I used isometric assets that we found earlier in Part 1, specifically this animated isometric spider pack. These assets provide smooth animations that blend well with our game's style.

First, I added a state enum for Spider to track its behaviour and transitions between different states. This enum will help manage the spider's animations and interactions, ensuring smooth state transitions during gameplay.
internal enum SpiderState
{
Idle, // The spider remains still
Rise, // The spider appears and becomes active
Die // The spider is defeated and removed
}
Next, I created the SpiderTileSetManager to handle the spider's animations and ensure the correct frames are displayed based on its state:
internal class SpiderTileSetManager : BaseTileSetManager
{
private static readonly string[] TileSetsPaths = new[]
{
ZeroSpiderRise,
ZeroSpiderIdle,
ZeroSpiderDie,
ZeroSpiderRiseShadow,
ZeroSpiderIdleShadow,
ZeroSpiderDieShadow
};
public SpiderTileSetManager(int tileWidth, int tileHeight) :
base(TileSetsPaths, tileWidth, tileHeight){}
public (TileSet, TileSet) GetRiseAnimation() =>
(TileSets[FileNames.FindIndex(
str => str == ZeroSpiderRise)],
TileSets[FileNames.FindIndex(
str => str == ZeroSpiderRiseShadow)]);
public (TileSet, TileSet) GetIdleAnimation =>
(TileSets[FileNames.FindIndex(
str => str == ZeroSpiderIdle)],
TileSets[FileNames.FindIndex(
str => str == ZeroSpiderIdleShadow)]);
public (TileSet, TileSet) GetDeathAnimation =>
(TileSets[FileNames.FindIndex(str => str == ZeroSpiderDie)],
TileSets[FileNames.FindIndex(str => str ==
ZeroSpiderDieShadow)]);
}

I added a spawn loop for spiders, ensuring that new enemies appear at regular intervals. A spider will spawn every second until the total number reaches five, maintaining a balanced challenge for the player.
internal void StartSpawnLoop(BindableObject view)
{
_pageIsActive = true;
view.Dispatcher.StartTimer(
TimeSpan.FromMilliseconds(SpawnCycleTime),
() => { if (_spiders.Count < SpiderLimit &&
_gameFieldWidth != 0 &&
_gameFieldHeight != 0) {
_spiders.Add(
new SpiderModel(_spiderTileSetManager) {
X = _rng.Next(50, (int)(_gameFieldWidth *
DeviceDisplay.MainDisplayInfo.Density - TileSize - 50)),
Y = _rng.Next(50, (int)(_gameFieldHeight *
DeviceDisplay.MainDisplayInfo.Density - TileSize - 50)),
CurrentState = SpiderState.Rise,
CurrentTileSets =
_spiderTileSetManager.GetRiseAnimation(),
ScaledSize = TileSize,
Id = _spiderCount
});
_spiderCount++;
}
return _pageIsActive;
});
}
To better understand the spider's logic, let’s break down its behavior. When a spider is added to the canvas, it begins by playing the "rise" animation, making a dramatic entrance onto the scene. Once this animation is complete, the spider transitions into the "idle" state, where it stays until it is defeated. During its idle state, the spider is essentially waiting for the Hydra to engage with it or for other game triggers to occur. When the spider dies, it plays the "death" animation, which visually marks its defeat. After the animation finishes, the spider disappears from the canvas, no longer posing a threat to the player. This state-based behavior allows us to manage the spider's lifecycle in a simple yet effective manner.
To implement this behavior, I created a Spider model that encapsulates the spider's properties and state transitions. This model tracks its current position, state, and manages the transitions between rise, idle, and death animations. It also contains logic for updating its state based on interactions with the Hydra or other game elements.
internal class SpiderModel
{
private const float HitBoxSize = 125.0f;
public int Id;
public required float X { get; init; }
public required float Y { get; init; }
public float ScaledSize { get; init; }
public SpiderState CurrentState { get; set; }
public required (TileSet Body, TileSet Shadow) CurrentTileSets
{ get; set; }
private int _animationIndex;
private readonly SpiderTileSetManager _tileSetManager;
public SpiderModel(SpiderTileSetManager tileSetManager)
{
_tileSetManager = tileSetManager;
}
public int AnimationIndex
{
get => _animationIndex;
set {
if (_animationIndex<CurrentTileSets.Body.TilesCount-1)
{
_animationIndex = value;
}
else
{
if (CurrentState != SpiderState.Die)
_animationIndex = 0;
if (CurrentState == SpiderState.Rise)
{
CurrentState = SpiderState.Idle;
CurrentTileSets = _tileSetManager.GetIdleAnimation;
}
}
}
}
public SKRect ScaleRect => new(X, Y, ScaledSize + X, ScaledSize + Y);
public SKRect HitBox=>new(ScaledSize / 2 - HitBoxSize / 2 + X,
ScaledSize / 2 - HitBoxSize / 2 + Y,
ScaledSize / 2 + HitBoxSize / 2 + X,
ScaledSize / 2 + HitBoxSize / 2 + Y);
public void Die()
{
if (CurrentState != SpiderState.Die)
{
AnimationIndex = 0;
CurrentState = SpiderState.Die;
CurrentTileSets = _tileSetManager.GetDeathAnimation;
}
}
}

I added a new method in the HydraModel to handle the attack animation. This method ensures that the Hydra plays the correct animation when it performs an attack, providing a visual cue for players. Additionally, I introduced a property to retrieve the HurtBox, which defines the area around the Hydra that can interact with enemies, such as detecting collisions with spiders. This property is crucial for implementing damage mechanics, ensuring that when an enemy enters the HurtBox during an attack, the appropriate interaction (such as reducing health or triggering a death animation) occurs.
public void Attack(Vector2 enemyPosition, int enemyIndex)
{
AttackedEnemyId = enemyIndex;
XDirection = 0;
YDirection = 0;
if (State != HydraState.Attack)
{
var xDifference = enemyPosition.X - CurrentPoint.X *
DeviceDisplay.MainDisplayInfo.Density;
var yDifference = enemyPosition.Y - CurrentPoint.Y *
DeviceDisplay.MainDisplayInfo.Density;
_xAttackDirection = 0;
_yAttackDirection = 0;
if(Abs(xDifference)-Abs(yDifference) is < 70 and > -70)
{
_xAttackDirection = (int)(xDifference / Abs(xDifference));
_yAttackDirection = (int)(yDifference / Abs(yDifference));
}
else if (Abs(xDifference) > Abs(yDifference))
{
_xAttackDirection = (int)(xDifference / Abs(xDifference));
}
else if (Abs(xDifference) < Abs(yDifference))
{
_yAttackDirection = (int)(yDifference / Abs(yDifference));
}
CurrentTileSets = _tileSetManager
.GetAttackAnimationTileSets(_xAttackDirection, _yAttackDirection);
AnimationIndex = 0;
State = HydraState.Attack;
}
}
public SKRect HurtBox => new
(ScaledSize / 2 - HurtBoxWidth / 2 + XTranslate,
ScaledSize / 2 - HurtBoxHeight / 2 + YTranslate,
ScaledSize / 2 + HurtBoxWidth / 2 + XTranslate,
ScaledSize / 2 + HurtBoxHeight / 2 + YTranslate);
I added a new loop in the view model to check for collisions between the Hydra and the Spiders:
internal void StartLogicLoop(BindableObject view)
{
view.Dispatcher.StartTimer(
TimeSpan.FromMilliseconds(LogicCycleTime),
() =>
{
for (var i = 0; i < _spiders.Count; i++)
{
var intersection = SKRect.Intersect(_hydra.HurtBox,
_spiders[i].HitBox);
if(_spiders[i].AnimationIndex >=
_spiders[i].CurrentTileSets.Body.TilesCount - 1 && _spiders[i].CurrentState == SpiderState.Die)
{
_spiders.RemoveAt(i);
break;
}
if (!intersection.IsEmpty)
{
if (_spiders[i].CurrentState == SpiderState.Idle && _hydra.State != HydraState.Attack &&
_spiders[i].Id != _hydra.AttackedEnemyId)
{
_hydra.Attack(new Vector2(_spiders[i].X +
_spiders[i].ScaledSize / 2,
_spiders[i].Y + _spiders[i].ScaledSize/2), _spiders[i].Id);
}
if (_hydra.AnimationIndex >=
_hydra.CurrentTileSets.Body.TilesCount - 5 && _hydra.State == HydraState.Attack && _spiders[i].Id == _hydra.AttackedEnemyId)
{
if (_spiders[i].CurrentState !=
SpiderState.Die)
{
_spiders[i].Die();
}
}
}
}
return _pageIsActive;
});
}
In the Hydra attack animation, 16 frames, this animation can be divided into three parts:
First - preparation
Second - attack
Third - recovery
These three phases together make the attack feel complete and visually satisfying, while also supporting the game’s combat mechanics.
The attack part of the Hydra's animation ends on frame 11, and to make the spider react appropriately after the attack, I used the condition _hydra.CurrentTileSets.Body.TilesCount - 5. This condition checks when the attack animation has reached its end (frame 11), allowing the spider to react at the right moment. By subtracting 5 from the total number of frames, we ensure that the spider reacts slightly after the attack animation concludes, giving a more natural and timely response.
I also updated the setter for the AnimationIndex property to enhance the Hydra’s behavior. Now, when the attack animation finishes, it ensures the Hydra stops playing the attack animation and either continues moving towards the target point or stops moving if it has already reached its destination. This provides a smooth transition between the attack and movement states, improving the overall responsiveness of the character.
Additionally, I introduced a property to track the attacked enemy index. This allows us to identify which spider was attacked during the animation, enabling us to trigger specific reactions or behaviors for that particular enemy.
set
{
if (_animationIndex < CurrentTileSets.Body.TilesCount - 1)
{
_animationIndex = value;
}
else
{
_animationIndex = 0;
if (State == HydraState.Attack)
{
State = HydraState.Move;
}
}
}
In the view model, I added a PaintSurface method for the spider canvas to render the spiders and their animations correctly. This method ensures the spiders are drawn with the right state and visual updates, including movement and effects, during each frame.
public void SpiderCanvasPaintSurface(object? sender,
SKPaintSurfaceEventArgs args)
{
var surface = args.Surface;
var canvas = surface.Canvas;
canvas.Clear();
foreach (var spider in _spiders)
{
canvas.DrawBitmap(spider.CurrentTileSets.Shadow.TilesBitmap,
spider.CurrentTileSets.Shadow.TilesData[spider.AnimationIndex].TileRect,
spider.ScaleRect);
canvas.DrawBitmap(spider.CurrentTileSets.Body.TilesBitmap, spider.CurrentTileSets.Body.TilesData[spider.AnimationIndex].TileRect,
spider.ScaleRect);
canvas.DrawRect(spider.HitBox, _debugPaint);
spider.AnimationIndex++;
}
}
❗️To check collisions between characters in debug mode, I added a square to the HydraCanvasPaintSurface for visualising the Hydra’s HitBox. This helps track the character's interaction area during development.❗️
Additionally, I updated the AnimationCycleTime to 45.0f, which adjusts the speed of the Hydra's animations, making them smoother and more responsive.
canvas.DrawRect(_hydra.HurtBox, _debugPaint);

Summary
In conclusion, we’ve made significant strides by adding spiders 🕷️, refining the Hydra’s attack mechanics 💥, and improving the animation system 🎬. The gameplay is becoming more dynamic, but this is just the beginning! We’re developing this game on .NET MAUI 🛠️, leveraging its capabilities to build a cross-platform experience.
In the next part, we’ll focus on enhancing the UI 🖼️ to create a more engaging player experience. We'll also refine the game logic and add the final touches, bringing us closer to completing the core mechanics. Stay tuned for more as we continue to build and evolve this exciting project! 🚀
The full code from this article will be here 📂.
📖 Missed the Previous Parts? Catch Up Here!
If you haven’t read the earlier parts of our Game Development on MAUI journey, check them out:
🔹 Part 1: Setting Up Animations – Bringing our Hydra 🐉 to life with animations and sprite management.
🔹 Part 2: Movement Mechanics – Implementing smooth movement, animation transitions, and adding shadows for depth.
Comments