3
\$\begingroup\$

I've been self-studying HTML/CSS/JS. I read about Wa-tor. It's a population dynamics simulation which simulates fish and sharks breeding and predating. This seemed cool so I decided to implement it. I don't know much about javascript, my background is mostly in python and R.

Here's my implementation. Just hit Play. And here's the source code.

Wa-tor

var intervalId;
var isPaused;

function randomChoice(arr) {
    return arr[Math.floor(Math.random() * arr.length)];
}

function tick(sea) {
    sea.tick();
    sea.draw();
}

function play() {
    var sea = new Sea(seaCanvas.width, seaCanvas.height);
    sea.generate();
    sea.draw();
    clearInterval(intervalId);
    intervalId = setInterval(tick, 1, sea);
}

Sea

class Sea {

    constructor(width, height) {
        this.width = width;
        this.height = height;
        this.numFish = 0;
        this.numSharks = 0;
        this.chronon = 0;
        this.ctx = document.getElementById('seaCanvas').getContext('2d', { willReadFrequently: true });
        this.array = new Array();
        for (let j = 0; j < this.height; j++) {
            var row = new Array();
            for (let i = 0; i < this.width; i++) {
                row.push(null);
            }
            this.array.push(row)
        }
    }

    getNorthCellCoords(x, y) {
        if (y !== 0) {
            return {
                x: x,
                y: y - 1
            };
        } else {
            return {
                x: x,
                y: this.height - 1
            };
        }
    }

    getWestCellCoords(x, y) {
        if (x !== 0) {
            return {
                x: x - 1,
                y: y
            };
        } else {
            return {
                x: this.width - 1,
                y: y
            };
        }
    }

    getSouthCellCoords(x, y) {
        if (y !== this.height - 1) {
            return {
                x: x,
                y: y + 1
            };            
        } else {
            return {
                x: x,
                y: 0
            };
        }
    }

    getEastCellCoords(x, y) {
        if (x !== this.width - 1) {
            return {
                x: x + 1,
                y: y
            };
        } else {
            return {
                x: 0,
                y: y
            }
        }
    }

    getCell(x, y) {
        return this.array[y][x];
    }

    getAdjacentCoords(x, y) {
        var coords = new Array();
        coords.push(this.getNorthCellCoords(x, y));
        coords.push(this.getWestCellCoords(x,y));
        coords.push(this.getSouthCellCoords(x,y));
        coords.push(this.getEastCellCoords(x,y));
        return coords;
    }

    getEmptyAdjacentCoords(x, y) {
        var coords = this.getAdjacentCoords(x, y);
        var empty = new Array();
        for (let i = 0; i < coords.length; i++) {
            let testX = coords[i].x;
            let testY = coords[i].y;
            if (this.array[testY][testX] == null) {
                empty.push(coords[i]);
            }
        }
        return empty;
    }

    getAdjacentFishCoords(x, y) {
        var coords = this.getAdjacentCoords(x, y);
        var fish = new Array();
        for (let i = 0; i < coords.length; i++) {
            let testX = coords[i].x;
            let testY = coords[i].y;
            if (this.array[testY][testX] != null) {
                if (this.array[testY][testX].species === 'fish') {
                    fish.push(coords[i]);
                }
            }
        }
        return fish;
    }

    move(origin, destination) {
        this.array[origin.y][origin.x].x = destination.x;
        this.array[origin.y][origin.x].y = destination.y;
        this.array[destination.y][destination.x] = this.array[origin.y][origin.x];
        this.array[origin.y][origin.x] = null;
    }

    addFish(origin, chronon) {
        this.numFish++;
        this.array[origin.y][origin.x] = new Fish(this, origin.x, origin.y, chronon);
    }

    addShark(origin, chronon) {
        this.numSharks++;
        this.array[origin.y][origin.x] = new Shark(this, origin.x, origin.y, chronon);
    }

    kill(coord) {
        if (this.array[coord.y][coord.x].species === 'fish') {
            this.numFish--;
        } else if (this.array[coord.y][coord.x].species === 'shark') {
            this.numSharks--;
        }
        this.array[coord.y][coord.x] = null;
    }

    generate() {
        let fishPct = document.getElementById('fishPct').value / 100;
        let sharkPct = document.getElementById('sharkPct').value / 100;
        for (let x = 0; x < this.width; x++) {
            for (let y = 0; y < this.height; y++) {
                let r = Math.random();
                if (r < fishPct) {
                    this.array[y][x] = new Fish(this, x, y, 0);
                    this.numFish++;
                } else if (r < fishPct + sharkPct) {
                    this.array[y][x] = new Shark(this, x, y, 0);
                    this.numSharks++;
                } else {
                    this.array[y][x] = null;
                }
            }
        }
    }

    draw() {
        const imageData = this.ctx.getImageData(0, 0, this.ctx.canvas.width, this.ctx.canvas.height);
        const data = imageData.data;
        for (let x = 0; x < this.width; x++) {
            for (let y = 0; y < this.height; y++) {
                let i = 4 * (y * this.width + x);
                if (this.array[y][x] == null) {
                    // white water
                    data[i] = 255;
                    data[i + 1] = 255;
                    data[i + 2] = 255;
                    data[i + 3] = 255;
                } else if (this.array[y][x].species === 'fish') {
                    // yellow fish
                    data[i] = 255;
                    data[i + 1] = 255;
                    data[i + 2] = 0;
                    data[i + 3] = 255;
                } else if (this.array[y][x].species === 'shark') {
                    // red sharks
                    data[i] = 255;
                    data[i + 1] = 0; 
                    data[i + 2] = 0;
                    data[i + 3] = 255;
                }
            }
        }
        this.ctx.putImageData(imageData, 0, 0);
        document.getElementById('chrononText').innerHTML = this.chronon;
        document.getElementById('fishText').innerHTML = this.numFish;
        document.getElementById('sharksText').innerHTML = this.numSharks;
    }

    tick() {
        this.chronon++;
        for (let x = 0; x < this.width; x++) {
            for (let y = 0; y < this.height; y++) {
                if (this.array[y][x] == null) {
                    // do nothing
                } else {
                    this.array[y][x].tick(this.chronon);
                }
            }
        }
    }
}

Animal

class Animal {
    constructor(sea, x, y, chronon) {
        this.sea = sea;
        this.x = x;
        this.y = y;
        this.chronon = chronon; 
        this.species = null;
        this.hasMoved = false;
        this.turnsToBreed = 0;
        this.turnsToStarve = 0;    
    }
}

Fish

class Fish extends Animal {
    constructor(sea, x, y, chronon) {
        super(sea, x, y, chronon);
        this.turnsToBreed = document.getElementById('fishBreedTurns').value;
        this.species = 'fish';
    }

    tick(chronon) {
        if (chronon === this.chronon) {
            // already moved            
        } else {
            // yet to move
            var empty = this.sea.getEmptyAdjacentCoords(this.x, this.y);
            if (empty.length === 0) {
                // no empty adjacent cells
            } else {
                // there is at least one empty adjacent cell available
                var origin = {x: this.x, y: this.y};
                var destination = randomChoice(empty);
                if (this.turnsToBreed <= 0) {
                    this.turnsToBreed = document.getElementById('fishBreedTurns').value;
                    this.sea.move(origin, destination);
                    this.sea.addFish(origin, this.chronon + 1);
                } else {
                    this.sea.move(origin, destination);
                }
            }
            this.chronon++;
            this.turnsToBreed--;
        }        
    }
}

Shark

class Shark extends Animal {
    constructor(sea, x, y, chronon) {
        super(sea, x, y, chronon);
        this.turnsToBreed = document.getElementById('sharkBreedTurns').value;
        this.turnsToStarve = document.getElementById('sharkStarveTurns').value;
        this.species = 'shark';
    }

    tick(chronon) {
        if (chronon === this.chronon) {
            // already moved
        } else {
            // yet to move
            var fish = this.sea.getAdjacentFishCoords(this.x, this.y);
            if (fish.length === 0) {
                // no adjacent fish
                var empty = this.sea.getEmptyAdjacentCoords(this.x, this.y);
                if (empty.length === 0) {
                    // no empty adjacent cells
                } else {
                    // there is at least one empty adjacent cell available
                    var origin = {x: this.x, y: this.y};
                    var destination = randomChoice(empty);
                    if (this.turnsToBreed <= 0) {
                        this.turnsToBreed = document.getElementById('sharkBreedTurns').value;
                        this.sea.move(origin, destination);
                        this.sea.addShark(origin, this.chronon + 1);
                    } else {
                        this.sea.move(origin, destination);
                    }
                }
            } else {
                // devourin' time!
                var origin = {x: this.x, y: this.y};
                var destination = randomChoice(fish);
                this.turnsToStarve = document.getElementById('sharkStarveTurns').value;
                if (this.turnsToBreed <= 0) {
                    this.turnsToBreed = document.getElementById('sharkBreedTurns').value + 1;
                    this.sea.kill(destination);
                    this.sea.move(origin, destination);
                    this.sea.addShark(origin, this.chronon + 1);
                } else {
                    this.sea.kill(destination);
                    this.sea.move(origin, destination);
                }
            }
            this.chronon++;
            this.turnsToBreed--;
            this.turnsToStarve--;
            if (this.turnsToStarve === 0) {
                this.sea.kill({x: this.x, y: this.y});
            }
        }
    }
}

I had a lot of questions pop up while I was writing this. Some of them:

  1. Did I take the right approach by using the Canvas? I considered SVG but read that Canvas is better for many entities, especially if I did one-to-one pixels. Would the next step be GL for this kind of thing?
  2. Did I use JS's "class" system properly? I don't think I took full advantage of Fish and Shark inheriting from Animal.
  3. Did I organize my files properly? I feel like three .js files may be overkill.
  4. What does my code smell like?
  5. Did I miss out on any helpful design patterns?

I don't have much experience doing this, so some guidance would be enormously helpful. Thank you.

\$\endgroup\$
2
  • \$\begingroup\$ Thank you. Added...is it too much? \$\endgroup\$
    – Jeff
    Oct 18, 2022 at 13:45
  • 1
    \$\begingroup\$ We accept quite large programs for review - that's not a problem here! \$\endgroup\$ Oct 18, 2022 at 14:09

1 Answer 1

2
\$\begingroup\$

Pretty good!

To answer your questions:

  1. Definitely, for pixel-sized entities, bitmap is imho perfect choice :)
  2. You could definitely design your code more polymorphic. tick method could be already in your Animal class and possibly taking care of some of the common stuff like checking for correct chronon value automatically. Imagine one method in animal, that does this check and if this check passes, it calls another method and that method actually does the job (and is implemented in your subclasses Shark and Fish). There could be more methods in Animal probably depending on your design. It seems weird to call sea.move(fish) (should sea move the fish? shouldn't fish move on it's own?).
  3. It's always good to split into more smaller pieces :) Although it's good practice to merge all your .js files into one big when in browser. webpack is one of common tools for that.
  4. Hah, smells fine!
  5. Take a look at "game loop", polymorphism, "Law of demeter".

Some things to modify:

  • Is the check of chronon chronon === this.chronon really necessary? Does that really happen? I would expect the calling code makes sure it is always called only once per frame. This just increases nesting and doesn't look too good. In case you need this in your code, I would use negative condition with return to reduce nesting:

    if (chronon !== this.chronon) return

  • I don't like referencing settings from your classes through html divs. Is it because you want the settings to be dynamic and it's possible to modify them while the game is running? Even so, I would prefer a wrapper that has access to those settings so that UI is not so tightly bound to your logic.

  • Very often you have branching and then there's a lot of shared code in both pathways. That is unnecessary duplication, that you could leave outside.

For example this:

if (this.turnsToBreed <= 0) {
    this.turnsToBreed = document.getElementById('sharkBreedTurns').value + 1;
    this.sea.kill(destination);
    this.sea.move(origin, destination);
    this.sea.addShark(origin, this.chronon + 1);
} else {
    this.sea.kill(destination);
    this.sea.move(origin, destination);
}

Can turn into this:

this.sea.kill(destination);
this.sea.move(origin, destination);
if (this.turnsToBreed <= 0) {
    this.turnsToBreed = document.getElementById('sharkBreedTurns').value + 1;
    this.sea.addShark(origin, this.chronon + 1);

}
  • You never really check for correct calls. For example the code allows adding fish to a location where already is one. It removes the old fish, but increments the count. There may be hidden some small bugs in your code, because you don't really check for invalid states.

  • Your tick methods are too big, extract them into smaller ones with more specific purpose - moving, breeding, devouring, etc.

\$\endgroup\$
2
  • \$\begingroup\$ Wow thanks, I really appreciate your feedback. One follow-up question about webpack...its website says that "includ[ing] a script for each functionality...is hard to scale because loading too many scripts can cause a network bottleneck". I get that my project is too small for this problem, but why would it be a problem if the scripts are loaded one after one at the end of <body>? Is it because scripts might be using classes and methods from each other? \$\endgroup\$
    – Jeff
    Oct 18, 2022 at 14:58
  • 1
    \$\begingroup\$ You are welcome. I imagine one example would be loading your page on a very bad connection where downloading every file separately requires another http connection, which is expensive (ignoring Connection: keep-alive) and makes download slower than if it was single bigger file. HTTP 2.0 helps with this too. \$\endgroup\$
    – K.H.
    Oct 18, 2022 at 15:05

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.