DEV Community

Cover image for I turned C# compiler errors into a game of Snake
muneeb-devp
muneeb-devp

Posted on

I turned C# compiler errors into a game of Snake

Every C# editor has a tiny graphics card hiding in it. It only draws three colours, it only refreshes when you type, and most people spend their whole day trying to make it go blank.

It's the error squiggle. I built Snake on it.

Snake being played with red and yellow squiggles in a C# file

Squiggly is a NuGet package. Add it to a project, create a file with // squiggly on the first line, press Ctrl+. and pick New game: Snake. The snake's head is a red error. Its body is a row of yellow warnings. The food is those faint grey dots you've been ignoring for years.

It works in Visual Studio, VS Code (with C# Dev Kit) and Rider, and there's no plugin for any of them. It's just an analyzer.

There's Tetris and Minesweeper too, because once you have a screen, you kind of have to.

Wait, what's an analyzer?

If you've never written one: Roslyn, the C# compiler, lets you plug in your own checks. An analyzer is a class that gets handed your code and can report diagnostics. Those are the same kind of thing as CS0103: The name 'x' does not exist, and your editor draws them as squiggles. The severity picks the colour: errors are red, warnings are yellow (green in Visual Studio), and info shows up as a few grey dots.

The other half is code fixes, which is the lightbulb menu. A code fix provider says "I know how to fix diagnostic X" and offers menu entries. Each entry is an edit to the file.

So you've got something that can draw coloured marks anywhere in a file, and a menu of buttons that can edit that file. That's a screen and a controller. I'm sorry. I couldn't not.

The screen is a comment

An analyzer can only underline text that already exists, so the game needs something to draw on. When you start a game, Squiggly writes a block of comments into the file:

// squiggly v1 game=snake seed=8812
//|........................................|
//|........................................|
//|........................................|
   (10 rows like this)
// keys:
Enter fullscreen mode Exit fullscreen mode

Each board cell is two characters wide, because monospace characters are about twice as tall as they are wide, and two of them make a roughly square cell. To light up a cell, the analyzer reports a diagnostic that covers those two characters. That's the whole rendering engine. A pixel is a Diagnostic with a two-character Location.

And since it's all comments, the file still compiles. Well, it compiles until a red pixel shows up. More on that later.

Version one was miserable to play

My first plan was that the lightbulb would be the gamepad. Caret on the header, Ctrl+., Move up. Ctrl+., Move up. Ctrl+., Move left.

It worked, and it was awful. Playing Snake through a context menu feels like filing a tax return. In Rider I was pressing Option+Enter for every single step, and it got old fast.

What I actually wanted was arrow keys. But analyzers can't see the keyboard. No key events, no mouse, no clock. They get the text of the file and that's it. Arrow keys don't change the text, they just move the caret, so as far as Roslyn knows, nothing happened.

So the controls had to be text too. There's a // keys: line under the board, and you type on it: w a s d to steer. Each letter changes the document, the editor re-runs the analyzer, and the snake moves one step. If you hold d down, key repeat makes the snake glide across the board, which is as close to real time as this thing will ever get.

The analyzer isn't allowed to remember anything

This was the part I found most interesting.

My first header stored the whole game: direction, head position, tail, where the food was. Something like dir=R head=10,5 tail=LL food=0,2. But once you're typing moves, that header is stale straight away, because an analyzer can't write to the file. Only code fixes can.

Keeping the game in memory doesn't work either. Roslyn runs analyzers concurrently, reloads them whenever it feels like it, and sometimes runs them on an older snapshot of the document. A static field holding "the current game" would drift away from what's on screen pretty quickly. Roslyn's own analyzer rules warn you about exactly this.

So the header shrank to two things, the game and a seed:

// squiggly v1 game=snake seed=8812
Enter fullscreen mode Exit fullscreen mode

On every keystroke, Squiggly starts a brand new game from that seed and replays every key on the keys line:

public static GameState Replay(IGame game, GameState state, string keys)
{
    foreach (var action in game.ParseKeys(keys))
    {
        state = game.Step(state, action);
    }

    return state;
}
Enter fullscreen mode Exit fullscreen mode

Then it draws whatever state it ends up with. Every frame is computed from scratch. That sounds wasteful, but replaying a 2,000-move game and rendering it takes about a quarter of a millisecond on my M1 Pro, and nobody is getting to 2,000 moves of Snake in a comment.

Doing it this way gave me a bunch of things for free:

  • Undo. Ctrl+Z deletes the last key, and the snake takes a step back.
  • Save games. The file is the save. Commit it, push it, open a PR with your high score.
  • Cheating. Died? Delete the last few letters. I can't stop you, and honestly I don't want to.
  • Tests. Same seed plus same keys always gives the same board, so the tests check exact boards for known games.

Randomness that doesn't flicker

All of that only holds if the randomness repeats. If the food spawned somewhere new every time the analyzer ran, it would teleport around while you typed. So there's no System.Random and no clock anywhere in the game code.

Squiggly uses SplitMix64, a tiny random number generator you can write in about a dozen lines, seeded with the game seed and the move number:

var random = Prng.For(state.Seed, state.Tick, stream: 1);
Enter fullscreen mode Exit fullscreen mode

"Seed 8812, move 40" puts the food in the same cell on every machine. I also didn't want System.Random because its algorithm is an implementation detail that has changed between .NET versions, and I didn't want your saved game to change when you updated the SDK.

Things the IDEs taught me

Before writing any game code, I built a throwaway spike to see whether the idea held up at all. A few things I learned:

  • Rider won't offer code fixes on hidden diagnostics. My plan was to hang the menu on an invisible diagnostic on the header line. Rider just ignored it. So the visible one, the HUD that says "Snake, score 4, length 7", became the menu anchor instead.
  • The colours belong to your theme, not to me. I get three severities and that's it. VS Code draws them red, yellow and grey. Visual Studio makes warnings green. So the palette is "whatever your theme thinks an error looks like".
  • If an analyzer throws, the IDE switches it off. So everything is wrapped, and an internal error shows up as a warning on the header instead of killing the game.
  • Code fixes need their own DLL. Roslyn wants analyzers and code fixes in separate assemblies, since the command-line compiler loads analyzers but has no use for editor code. The package ships two DLLs, with the game engine compiled into the analyzer.

Not slowing down your actual code

The analyzer runs on every file in your project, and almost none of them are games. So the first thing it does is check the first five lines for // squiggly. If that's not there, it stops. On a 5,000-line file that check takes about 90 nanoseconds and allocates nothing.

Still, put it in its own little "Arcade" project, not in your work code. The pixels are real diagnostics, so a red pixel is a real compile error, and dotnet build fails while a game is in progress. Install it in your main project and you'll get the most honest CI failure of your career: build failed, losing at Snake.

(If you want a green build mid-game, you can lower the severities in .editorconfig. Your snake changes colour, but that's the deal.)

Tetris and Minesweeper

Tetris uses the same trick. a and d move, w rotates, s drops a row and x slams it down. The falling piece is red, locked blocks are yellow, and the grey dots show where it's going to land.

Tetris being played with squiggles in a C# file

Minesweeper is the odd one out. You pick cells instead of steering, so every covered cell gets its own diagnostic, and the lightbulb on a cell offers Reveal or Flag. This is the one game where the menu is actually nice to use. Your first reveal is always safe, because I'm not a monster.

Try it

dotnet new console -n Arcade && cd Arcade
dotnet add package Squiggly
echo "// squiggly" > Game.cs
Enter fullscreen mode Exit fullscreen mode

Open Game.cs, put the caret on line 1, press Ctrl+. (Alt+Enter in Rider) and pick a game. Then click at the end of the // keys: line and start typing.

The code is on GitHub at muneeb-devp/squiggly. If you've never written an analyzer, there's a beginner's guide in the repo that follows one keystroke all the way through. It's a surprisingly good way to learn how analyzers and code fixes work, since your bugs literally crawl across the screen.

If you get a good Snake score, post your keys line in the comments. I'll replay it. I'll probably believe you.

Top comments (0)