GitHub Contributions Chrome Snake extension

It’s Saturday afternoon on a dreary day in England, storm Darragh is wreaking havoc outside. Advent of code: completed, bed drawers: fixed, plug sockets: changed, Zwift race: 8th. Now as the storm rages on, I can finally code my silly idea that absolutely nobody asked for.

The infamous github contributions:

Now, wouldn’t it be cool if you could play Snake in there instead? Well, fever dream Luke thought so.

So how do I even start? I’ve made a chrome extension before but I’m sure it’s all changed since then. Get started with chrome extensions is probably a good bet.

It’s pointed me to this starter application that I’ll checkout locally and go from there. Let’s open up the beautiful — now free — Webstorm and get started.

Well to get that going was pretty simple, I just enabled developer mode in chrome::extensions and loaded the unpacked directory I’d just copied:

Hopefully, this is enough to get me going.

The first thing I would like to do is to create a blank canvas of contributions, a non-comitter if you will. Inspecting the dom reveals it’s just a fancy table:

I’ve just noticed that each table data has a special attribute data-level, it looks like data-level=0 means you’re a slacker and data-level=4 means you’re a try-hard. Let’s query to set all of these to 0 to begin with. I think ideally I’ll get this all mapped to a 2 dimension array, but I’ll worry about that in a minute, let’s get the ball rolling.

Running document.querySelectorAll('.ContributionCalendar-day') returns 375 nodes, hmm, last I checked there wasn’t 375 days in a year.

Now who’s the tryhard:

I queried for the data-date attribute instead, which returns 371 days. I did some counting, there are 54 columns and 7 rows 54*7=371, fair enough. The extra 4 from the previous query were the little tiles at the bottom right of the above image.

This doesn’t seem to work however in my extension I suspect I need to add something for CORs or the like to get it going.

Well, it turns out the popup.js has its own DOM so I can’t grab it from there no matter how many document.parent's /tops I do. I don’t want this to be a popup anyway, I want it to just work when you go on the website. Content scripts are exactly what I’m looking for, just run straight away, put me in the context of the current page.

Isn’t it beautiful?

I had to update the manifest.json

{
  "name": "Hello Extensions",
  "description": "Base Level Extension",
  "version": "1.0",
  "manifest_version": 3,
  "action": {
    "default_popup": "hello.html",
    "default_icon": "hello_extensions.png"
  },
  "content_scripts": [
    {
      "matches": ["https://github.com/*"], // added this
      "js": ["content-script.js"]
    }
  ],
  "permissions": ["scripting", "activeTab"]
}

And the content-script.js itself, ignore me being lazy awaiting the content to load by adding a 3 second delay 🙂

setTimeout(() => {
    const nodes = document.querySelectorAll('[data-date]');
    if (nodes.length > 0) {
        setInterval(() => {
            nodes.forEach(node => {
                node.setAttribute('data-level', Math.floor(Math.random() * 4));
            });
        }, 100);
    }
}, 3000)

Remember that 2D grid I referred to earlier, well here it is:


function createGrid(nodes) {
    let row = 0;
    let grid = [];
    for (let i = 0; i < nodes.length; i++) {
        if (i % 53 == 0) {
            row++;
            grid[row] = [];
        }

        grid[row].push(nodes[i]);
    }
    return grid;
}

Now I need to figure out the game loop and pop the player in there. I’ll start off by setting the players position and then moving it.

    let x = 20;
    let y = 3;
    let direction = 0;
    grid[y][x].setAttribute('data-level', 4);
    setInterval(() => {

        grid[y][x].setAttribute('data-level', 0);
        if (direction === 0) x++;

        grid[y][x].setAttribute('data-level', 4);
    }, 300);

that’s simple enough. What’s next? I’m gonna have a look at some old code I wrote, and use it as a guide.

The next thing I added was key inputs, let’s do that. I’ll need to add some event listeners:

    document.addEventListener("keydown", (event) => {
        if (event.key === "ArrowUp") {
            if (direction === 0 || direction === 2) direction = 3;
            event.preventDefault();
        } else if (event.key === "ArrowDown") {
            if (direction === 0 || direction === 2) direction = 1;
            event.preventDefault();
        } else if (event.key === "ArrowLeft") {
            if (direction === 1 || direction === 3) direction = 2;
            event.preventDefault();
        } else if (event.key === "ArrowRight") {
            if (direction === 1 || direction === 3) direction = 0;
            event.preventDefault();
        }
    });

We’re getting somewhere! Time to add some food. That should be simple enough, I’ll start extracting all of this into some classes, it’s already getting a mess.

class Food {
    constructor() {
        this.spawn();
    }

    spawn() {
        this.x = Math.floor(Math.random() * 53);
        this.y = Math.floor(Math.random() * 7);
    }

    draw() {
        grid[this.y][this.x].setAttribute('data-level', 2);
    }
}

I’ve also updated it to reset the game state in each game loop iteration, it’s certainly not efficient but it’s how I’m used to coding games when using libraries like p5.js.

This is the game loop so far:

function startGame(nodes) {
    grid = createGrid(nodes);
    food = new Food();
    snake = new Snake();
    setInterval(() => {
        clear(nodes);
        food.draw();
        snake.update();
        snake.draw();
    }, 300);
}

When the snake eats some food, I want him to grow!


    update(food) {
        this.lastX = this.body[this.body.length-1].x
        this.lastY = this.body[this.body.length-1].y;
        for (let i = this.body.length-1; i >= 1; i--) {
            this.body[i].x = this.body[i-1].x;
            this.body[i].y = this.body[i-1].y;
        }

        if (this.direction === 0) this.body[0].x++;
        if (this.direction === 1) this.body[0].y++;
        if (this.direction === 2) this.body[0].x--;
        if (this.direction === 3) this.body[0].y--;

        if (food.x === this.body[0].x && food.y === this.body[0].y) {
            food.spawn();
            this.body.push({x: this.lastX, y: this.lastY});
        }

So I’ve added a body to the snake, which is simply an array. Each frame I’m shifting the body up, so the head moves on to the next square as it were, and the other parts of the body take the position of the body infront of it:

The only thing missing now is restarting the game when the player dies, either by hitting the edge of the map or hitting itself.

I created a dead() function in the snake class, that should do it:

    dead() {
        const isOutOfBounds = this.body[0].x < 0 || this.body[0].x > 52 || this.body[0].y < 0 || this.body[0].y > 6;
        if (isOutOfBounds) {
            return true;
        }

        for (let i = 1; i < this.body.length; i++) {
            if (this.body[i].x === this.body[0].x && this.body[i].y === this.body[0].y) {
                return true;
            }
        }
        return false;
    }

And I think that’s probably enough to satisfy the fever dream. If you want to have a look at the code I’ve popped it on my GitHub, it’s a bit ghastly but I rather want to have a go at the new Path of exiles, so it is what it is!

1 Comment

  1. Paula

    This is the sort of stuff I have fever dreams of too!

Leave a Reply

Your email address will not be published. Required fields are marked *