// RODENT'S REVENGE — push blocks, box the cats in, eat the cheese they leave behind.
// Click the board first (that hands the keyboard to the canvas), then arrows or WASD.
// ---- sprites -----------------------------------------------------------------------------
const string MouseSvg =
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'>" +
"<path d='M5 25c-3 1-4 4-1 6' fill='none' stroke='#6A7078' stroke-width='2' stroke-linecap='round'/>" +
"<path d='M8 28c-4 0-6-3-6-7 0-6 6-10 13-10 8 0 14 4 14 9 0 5-5 8-11 8z' fill='#B9BEC6' stroke='#5A6069' stroke-width='1.6'/>" +
"<circle cx='10' cy='13' r='5' fill='#D6DAE0' stroke='#5A6069' stroke-width='1.5'/>" +
"<circle cx='24' cy='19' r='1.7' fill='#23262B'/>" +
"<circle cx='29' cy='22' r='2' fill='#F09BB0' stroke='#C5788C' stroke-width='0.8'/>" +
"<path d='M22 24h8M22 26l7 2' fill='none' stroke='#8E959D' stroke-width='0.9'/></svg>";
const string CatSvg =
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'>" +
"<path d='M6 13 7 4l7 5zM26 13l-1-9-7 5z' fill='#E39A4A' stroke='#7A4A17' stroke-width='1.4' stroke-linejoin='round'/>" +
"<path d='M16 8c7 0 12 5 12 11 0 6-5 9-12 9S4 25 4 19c0-6 5-11 12-11z' fill='#E8A759' stroke='#7A4A17' stroke-width='1.5'/>" +
"<path d='M11 12l1 5M16 11v5M21 12l-1 5' fill='none' stroke='#B8752C' stroke-width='1.4' stroke-linecap='round'/>" +
"<circle cx='12' cy='20' r='2.6' fill='#F7F3E8'/><circle cx='20' cy='20' r='2.6' fill='#F7F3E8'/>" +
"<circle cx='12.4' cy='20' r='1.3' fill='#22201C'/><circle cx='19.6' cy='20' r='1.3' fill='#22201C'/>" +
"<path d='M16 23.5l-1.6 1.6h3.2z' fill='#C0603F'/>" +
"<path d='M3 22h6M3 25h6M23 22h6M23 25h6' fill='none' stroke='#F2E3CE' stroke-width='0.9'/></svg>";
const string CheeseSvg =
"<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'>" +
"<path d='M3 25 20 6l9 6v13z' fill='#F5C542' stroke='#A97C14' stroke-width='1.6' stroke-linejoin='round'/>" +
"<circle cx='11' cy='21' r='2.6' fill='#D9A62B'/><circle cx='22' cy='17' r='2.1' fill='#D9A62B'/>" +
"<circle cx='19' cy='22.5' r='1.7' fill='#D9A62B'/></svg>";
// ---- board -------------------------------------------------------------------------------
// The board is derived, not typed in: mouse in the centre cell, SlabReach blocks around it, a
// Lane-wide corridor for the cats, then the wall ring. That keeps it square, keeps the mouse
// centred, and makes both margins impossible to get wrong.
const int SlabReach = 3; // blocks between the mouse and the corridor, in every direction
const int Lane = 2; // corridor the cats prowl, on all four sides
const int Cols = (SlabReach + Lane + 1) * 2 + 1, Rows = Cols, MaxCats = 6;
const double Cell = 28;
const int CellEmpty = 0, CellBlock = 1, CellWall = 2, CellCheese = 3;
var grid = new int[Cols * Rows];
var shown = new int[Cols * Rows];
var cats = new List<(int x, int y, int lx, int ly)>(); // lx/ly = the step it just took
var spawns = new (int x, int y)[]
{
(1, 1), (Cols - 2, Rows - 2), (Cols - 2, 1), (1, Rows - 2), (Cols / 2, 1), (Cols / 2, Rows - 2),
};
var mouseX = Cols / 2;
var mouseY = Rows / 2;
var lives = 3;
var score = 0;
var level = 1;
var gameOver = false;
var helpOpen = false; // the cats hold still while the rules are up
var stunned = false; // the beat after a cat lands on you, before the board resets
double dragX = 0, dragY = 0; // anchor of the current drag, in board points
var dragStepped = false;
var cellViews = new SkiaShape[Cols * Rows];
var catViews = new SkiaSvg[MaxCats];
var cheeseViews = new SkiaSvg[MaxCats];
SkiaSvg mouseView = null;
SkiaLabel headline = null;
SkiaLabel note = null;
SkiaLayer help = null;
SkiaLayer caught = null;
SkiaLabel caughtText = null;
var blockColor = Color.Parse("#B07C43");
var wallColor = Color.Parse("#5C3E22");
int At(int x, int y) => grid[y * Cols + x];
void Put(int x, int y, int what) => grid[y * Cols + x] = what;
bool Inside(int x, int y) => x >= 0 && x < Cols && y >= 0 && y < Rows;
bool CatOn(int x, int y) => cats.Any(c => c.x == x && c.y == y);
// a cat may step only onto bare floor — cheese and blocks are what boxes it in
bool Free(int x, int y) => Inside(x, y) && At(x, y) == CellEmpty && !CatOn(x, y);
// Cats walk on eight directions like the original, so both their steps and the "is it boxed in"
// test use all eight neighbours — and distance is Chebyshev, not Manhattan: a diagonal step
// closes both axes at once and Manhattan would price it the same as a sideways shuffle.
var around = new (int sx, int sy)[]
{
(1, 0), (-1, 0), (0, 1), (0, -1), (1, 1), (1, -1), (-1, 1), (-1, -1),
};
int Reach(int x, int y) => Math.Max(Math.Abs(mouseX - x), Math.Abs(mouseY - y));
void PlaceActors()
{
mouseX = Cols / 2;
mouseY = Rows / 2;
Put(mouseX, mouseY, CellEmpty);
cats.Clear();
for (int i = 0; i < Math.Min(1 + level, MaxCats); i++)
{
var spot = spawns[i];
Put(spot.x, spot.y, CellEmpty); // never spawn a cat inside a block it pushed nowhere
cats.Add((spot.x, spot.y, 0, 0));
}
}
void NewLevel()
{
for (int y = 0; y < Rows; y++)
for (int x = 0; x < Cols; x++)
Put(x, y, x == 0 || y == 0 || x == Cols - 1 || y == Rows - 1 ? CellWall : CellEmpty);
// the classic slab: 3 blocks from the mouse in every direction, built off the centre cell so
// it stays square and the open lane around it is the same width on all four sides
for (int y = Rows / 2 - SlabReach; y <= Rows / 2 + SlabReach; y++)
for (int x = Cols / 2 - SlabReach; x <= Cols / 2 + SlabReach; x++)
Put(x, y, CellBlock);
PlaceActors();
}
// Getting caught is the moment of the game — hold it for a beat instead of teleporting the mouse
// back. Everything freezes on the stun flag, the board flashes red, the mouse spins off its feet.
async void Caught()
{
if (stunned) return;
stunned = true;
lives--;
caughtText.Text = lives <= 0 ? "GAME OVER" : "GOTCHA!";
caught.IsVisible = true;
caught.AnimateOpacity(0.25, 1.0, 0.3, repeat: 4, pingPong: true);
_ = caughtText.ScaleToAsync(1.25, 1.25, 260, Easing.SpringOut);
_ = mouseView.RotateToAsync(720, 1100);
Refresh();
await System.Threading.Tasks.Task.Delay(1600);
caught.IsVisible = false;
caught.Opacity = 1;
caughtText.ScaleX = caughtText.ScaleY = 1;
mouseView.Rotation = 0;
if (lives <= 0)
gameOver = true;
else
NewLevel(); // a life costs you the whole board: blocks back in the slab, cheese gone
stunned = false;
Refresh();
}
/// A cat with no bare floor left around it turns into cheese. The mouse standing next to it
/// counts as an escape route, otherwise you could trap a cat by walking up to it.
void Boxed()
{
for (int i = cats.Count - 1; i >= 0; i--)
{
var (cx, cy, _, _) = cats[i];
var escapes = around.Any(d => Free(cx + d.sx, cy + d.sy)) || Reach(cx, cy) == 1;
if (escapes) continue;
Put(cx, cy, CellCheese);
cats.RemoveAt(i);
score += 200;
}
if (cats.Count == 0)
{
level++;
score += 500;
NewLevel();
}
}
bool Step(int dx, int dy)
{
if (gameOver || helpOpen || stunned) return false;
int nx = mouseX + dx, ny = mouseY + dy;
if (!Inside(nx, ny) || At(nx, ny) == CellWall) return false;
if (CatOn(nx, ny))
{
Caught();
return true;
}
if (At(nx, ny) == CellBlock)
{
// push the whole run of blocks if there is bare floor behind it
int rx = nx, ry = ny;
while (Inside(rx, ry) && At(rx, ry) == CellBlock) { rx += dx; ry += dy; }
if (!Free(rx, ry)) return false;
Put(rx, ry, CellBlock);
Put(nx, ny, CellEmpty);
}
else if (At(nx, ny) == CellCheese)
{
Put(nx, ny, CellEmpty);
score += 100;
}
mouseX = nx;
mouseY = ny;
if (dx != 0) mouseView.ScaleX = dx > 0 ? 1 : -1;
Boxed();
return true;
}
// Phones have no arrow keys: tap a square and the mouse takes one step towards it, along the
// axis you are furthest off. If that way is blocked it tries the other one, so a tap next to a
// wall still does the obvious thing.
void TapTowards(int cellX, int cellY)
{
int dx = Math.Sign(cellX - mouseX), dy = Math.Sign(cellY - mouseY);
if (dx == 0 && dy == 0) return;
var horizontalFirst = Math.Abs(cellX - mouseX) >= Math.Abs(cellY - mouseY);
var first = horizontalFirst ? (dx, 0) : (0, dy);
var second = horizontalFirst ? (0, dy) : (dx, 0);
if (first != (0, 0) && Step(first.Item1, first.Item2)) { Refresh(); return; }
if (second != (0, 0) && Step(second.Item1, second.Item2)) Refresh();
}
void CatsStep()
{
for (int i = 0; i < cats.Count; i++)
{
var (cx, cy, lx, ly) = cats[i];
var best = int.MaxValue;
(int sx, int sy) pick = (0, 0);
// Take the open neighbour that gets closest to the mouse. Turning back the way it came
// costs 3 — without that penalty a cat aligned with the mouse and blocked by the slab
// just bounces between two squares forever instead of walking around it.
foreach (var (sx, sy) in around)
{
int tx = cx + sx, ty = cy + sy;
if (tx == mouseX && ty == mouseY)
{
Caught();
return;
}
if (!Free(tx, ty)) continue;
// Chebyshev decides how many steps are left; Manhattan breaks the ties in favour of
// the diagonal, which closes both axes at once instead of walking an L.
var cost = Reach(tx, ty) * 2 + Math.Abs(mouseX - tx) + Math.Abs(mouseY - ty)
+ (sx == -lx && sy == -ly ? 3 : 0);
if (cost < best)
{
best = cost;
pick = (sx, sy);
}
}
if (pick != (0, 0))
cats[i] = (cx + pick.sx, cy + pick.sy, pick.sx, pick.sy);
}
Boxed();
}
void Refresh()
{
for (int i = 0; i < grid.Length; i++)
{
if (shown[i] == grid[i]) continue; // only squares that actually changed touch a control
shown[i] = grid[i];
var view = cellViews[i];
view.IsVisible = grid[i] == CellBlock || grid[i] == CellWall;
if (view.IsVisible)
view.BackgroundColor = grid[i] == CellWall ? wallColor : blockColor;
}
var cheese = 0;
for (int i = 0; i < grid.Length && cheese < MaxCats; i++)
{
if (grid[i] != CellCheese) continue;
cheeseViews[cheese].Left = i % Cols * Cell + 1;
cheeseViews[cheese].Top = i / Cols * Cell + 1;
cheeseViews[cheese].IsVisible = true;
cheese++;
}
for (int i = cheese; i < MaxCats; i++)
cheeseViews[i].IsVisible = false;
for (int i = 0; i < MaxCats; i++)
{
catViews[i].IsVisible = i < cats.Count;
if (i >= cats.Count) continue;
catViews[i].Left = cats[i].x * Cell + 1;
catViews[i].Top = cats[i].y * Cell + 1;
}
mouseView.Left = mouseX * Cell + 1;
mouseView.Top = mouseY * Cell + 1;
mouseView.IsVisible = !gameOver;
headline.Text = $"Level {level} Score {score} Lives {lives}";
note.Text = gameOver
? "The cats got you — press New game"
: cats.Count == 1 ? "1 cat left — box it in" : $"{cats.Count} cats — box them in";
}
// The cats tick on their own clock, so the board only repaints when something moved.
// An animator would repaint every frame instead; this loop dies with the control on recompile.
async void Prowl(SkiaControl root)
{
while (!root.IsDisposed && !root.IsDisposing)
{
await System.Threading.Tasks.Task.Delay(Math.Max(170, 400 - level * 25));
if (gameOver || helpOpen || stunned) continue;
CatsStep();
Refresh();
}
}
// ---- scene -------------------------------------------------------------------------------
return new SkiaShape
{
CornerRadius = 14,
BackgroundColor = Color.Parse("#241A12"),
Padding = new Thickness(14),
HorizontalOptions = LayoutOptions.Center,
VerticalOptions = LayoutOptions.Center,
Children = new List<SkiaControl>
{
new SkiaStack
{
Spacing = 7,
HorizontalOptions = LayoutOptions.Center,
Children = new List<SkiaControl>
{
new SkiaLabel("RODENT'S REVENGE")
{
FontSize = 16,
FontWeight = 800,
TextColor = Color.Parse("#F5C542"),
}.CenterX(),
new SkiaLabel("")
{
FontSize = 12,
TextColor = Color.Parse("#CDB894"),
}.CenterX().Assign(out headline),
new SkiaLayer
{
WidthRequest = Cols * Cell,
HeightRequest = Rows * Cell,
BackgroundColor = Color.Parse("#DBC59A"),
HorizontalOptions = LayoutOptions.Center,
VerticalOptions = LayoutOptions.Start,
Children =
// one square per cell: blocks and walls just switch it on
Enumerable.Range(0, Cols * Rows).Select(i => (SkiaControl)new SkiaShape
{
CornerRadius = 3,
WidthRequest = Cell - 1,
HeightRequest = Cell - 1,
Left = i % Cols * Cell,
Top = i / Cols * Cell,
HorizontalOptions = LayoutOptions.Start,
VerticalOptions = LayoutOptions.Start,
BackgroundColor = blockColor,
StrokeWidth = 1,
StrokeColor = Color.Parse("#33FFFFFF"),
IsVisible = false,
}.Assign(out cellViews[i]))
// cheese first, then cats, then the mouse on top
.Concat(Enumerable.Range(0, MaxCats).Select(i => (SkiaControl)new SkiaSvg
{
SvgString = CheeseSvg,
WidthRequest = Cell - 2,
HeightRequest = Cell - 2,
HorizontalOptions = LayoutOptions.Start,
VerticalOptions = LayoutOptions.Start,
IsVisible = false,
}.Assign(out cheeseViews[i])))
.Concat(Enumerable.Range(0, MaxCats).Select(i => (SkiaControl)new SkiaSvg
{
SvgString = CatSvg,
WidthRequest = Cell - 2,
HeightRequest = Cell - 2,
HorizontalOptions = LayoutOptions.Start,
VerticalOptions = LayoutOptions.Start,
IsVisible = false,
}.Assign(out catViews[i])))
.Append((SkiaControl)new SkiaSvg
{
SvgString = MouseSvg,
WidthRequest = Cell - 2,
HeightRequest = Cell - 2,
HorizontalOptions = LayoutOptions.Start,
VerticalOptions = LayoutOptions.Start,
}.Assign(out mouseView))
// the "a cat got you" flash, over the board only
.Append((SkiaControl)new SkiaLayer
{
HorizontalOptions = LayoutOptions.Fill,
VerticalOptions = LayoutOptions.Fill,
BackgroundColor = Color.Parse("#66C7362F"),
ZIndex = 15,
IsVisible = false,
Children = new List<SkiaControl>
{
new SkiaLabel("GOTCHA!")
{
FontSize = 30,
FontWeight = 900,
TextColor = Color.Parse("#FFE9E6"),
DropShadowColor = Color.Parse("#8C1E18"),
DropShadowSize = 2,
DropShadowOffsetX = 0,
DropShadowOffsetY = 2,
}.Center().Assign(out caughtText),
}
}.Assign(out caught))
.ToList()
}
// Touch: drag to walk (one step per cell of travel, like the match-3 swap drag), or
// tap a square to step towards it. Keyboard still works for anyone who has one.
.WithGestures((me, args, apply) =>
{
var inside = me.GetOffsetInsideControlInPoints(args.Event.Location, apply.ChildOffset);
if (args.Type == TouchActionResult.Down)
{
dragX = inside.X;
dragY = inside.Y;
dragStepped = false;
return me;
}
if (args.Type == TouchActionResult.Panning)
{
// a fast drag can cross several cells between two events, so walk them off
for (int guard = 0; guard < Cols; guard++)
{
double dx = inside.X - dragX, dy = inside.Y - dragY;
if (Math.Abs(dx) < Cell * 0.6 && Math.Abs(dy) < Cell * 0.6)
break;
var horizontal = Math.Abs(dx) >= Math.Abs(dy);
var sx = horizontal ? Math.Sign(dx) : 0;
var sy = horizontal ? 0 : Math.Sign(dy);
dragX += sx * Cell; // the anchor follows the finger cell by cell
dragY += sy * Cell;
dragStepped = true;
if (!Step(sx, sy))
break; // ran into a wall: wait for the finger to turn
Refresh();
}
return me;
}
if (args.Type == TouchActionResult.Up)
{
if (!dragStepped)
TapTowards((int)(inside.X / Cell), (int)(inside.Y / Cell));
return me;
}
return null;
}),
new SkiaLabel("")
{
FontSize = 12,
TextColor = Color.Parse("#A28D6C"),
}.CenterX().Assign(out note),
new SkiaRow
{
Spacing = 8,
HorizontalOptions = LayoutOptions.Center,
Children = new List<SkiaControl>
{
new SkiaButton("New game")
{
WidthRequest = 110,
HeightRequest = 32,
CornerRadius = 8,
FontSize = 13,
BackgroundColor = Color.Parse("#3E2C1B"),
TextColor = Color.Parse("#F0E2C8"),
}.OnTapped(me =>
{
level = 1;
score = 0;
lives = 3;
gameOver = false;
NewLevel();
for (int i = 0; i < shown.Length; i++) shown[i] = -1;
Refresh();
}),
new SkiaButton("Help")
{
WidthRequest = 70,
HeightRequest = 32,
CornerRadius = 8,
FontSize = 13,
BackgroundColor = Color.Parse("#3E2C1B"),
TextColor = Color.Parse("#F0E2C8"),
}.OnTapped(me =>
{
helpOpen = true;
help.IsVisible = true;
}),
new SkiaLabel("drag or tap the board, or arrows / WASD")
{
FontSize = 11,
TextColor = Color.Parse("#8A7758"),
VerticalOptions = LayoutOptions.Center,
},
}
},
}
},
// rules card: sits over everything, holds the cats still, closes on any tap
new SkiaLayer
{
HorizontalOptions = LayoutOptions.Fill,
VerticalOptions = LayoutOptions.Fill,
BackgroundColor = Color.Parse("#D2231A12"),
ZIndex = 20,
IsVisible = false,
Children = new List<SkiaControl>
{
new SkiaShape
{
CornerRadius = 12,
WidthRequest = 330,
BackgroundColor = Color.Parse("#2E2015"),
StrokeWidth = 1,
StrokeColor = Color.Parse("#5E452C"),
Padding = new Thickness(18, 16),
HorizontalOptions = LayoutOptions.Center,
VerticalOptions = LayoutOptions.Center,
Children = new List<SkiaControl>
{
new SkiaStack
{
Spacing = 9,
HorizontalOptions = LayoutOptions.Fill,
Children = new List<SkiaControl>
{
new SkiaLabel("HOW TO PLAY")
{
FontSize = 14,
FontWeight = 800,
TextColor = Color.Parse("#F5C542"),
}.CenterX(),
new SkiaLabel(
"You are the mouse. The cats want you.\n\n" +
"• Walk into a block to push it — a whole line slides if there is floor behind it.\n" +
"• Shut a cat in on all four sides and it turns into cheese.\n" +
"• Walk over cheese to eat it. Cheese also blocks cats, so it helps you box the next one.\n" +
"• A cat that reaches you costs a life. You have three.\n" +
"• Box every cat to clear the level; the next one brings more cats and faster paws.")
{
FontSize = 12,
LineSpacing = 1.15,
TextColor = Color.Parse("#DCC9A8"),
HorizontalOptions = LayoutOptions.Fill,
},
new SkiaLabel("cheese 100 cat 200 level 500")
{
FontSize = 12,
FontWeight = 700,
TextColor = Color.Parse("#C6A96F"),
}.CenterX(),
new SkiaLabel("Drag across the board to walk, or tap a square to step towards it — arrows / WASD work too")
{
FontSize = 11,
TextColor = Color.Parse("#9C8663"),
HorizontalTextAlignment = DrawTextAlignment.Center,
HorizontalOptions = LayoutOptions.Fill,
},
new SkiaButton("Got it")
{
WidthRequest = 100,
HeightRequest = 32,
CornerRadius = 8,
FontSize = 13,
BackgroundColor = Color.Parse("#5A4128"),
TextColor = Color.Parse("#F0E2C8"),
}
.CenterX()
.OnTapped(me =>
{
helpOpen = false;
help.IsVisible = false;
}),
}
}
}
},
}
}
.Assign(out help)
.OnTapped(me =>
{
helpOpen = false;
me.IsVisible = false;
}),
}
}
.OnKeyDown((me, key) =>
{
if (key == InputKey.ArrowLeft || key == InputKey.KeyA) Step(-1, 0);
else if (key == InputKey.ArrowRight || key == InputKey.KeyD) Step(1, 0);
else if (key == InputKey.ArrowUp || key == InputKey.KeyW) Step(0, -1);
else if (key == InputKey.ArrowDown || key == InputKey.KeyS) Step(0, 1);
else return;
Refresh();
})
.Initialize(me =>
{
for (int i = 0; i < shown.Length; i++) shown[i] = -1;
NewLevel();
Refresh();
Prowl(me);
});