Skip to content

Instantly share code, notes, and snippets.

@insipx
Created June 10, 2018 23:44
Show Gist options
  • Save insipx/c3d3eae31016c1e16ba6bd1a7e19b2fe to your computer and use it in GitHub Desktop.
Save insipx/c3d3eae31016c1e16ba6bd1a7e19b2fe to your computer and use it in GitHub Desktop.
Space Invaders HTML5 Canvas
<link href='https://fonts.googleapis.com/css?family=Play:400,700' rel='stylesheet' type='text/css'>
<canvas id="game-canvas" width="640" height="640"></canvas>
/* Simple JavaScript Inheritance
* By John Resig http://ejohn.org/
* MIT Licensed.
*/
// Inspired by base2 and Prototype
(function(){
var initializing = false, fnTest = /xyz/.test(function(){xyz;}) ? /\b_super\b/ : /.*/;
// The base Class implementation (does nothing)
this.Class = function(){};
// Create a new Class that inherits from this class
Class.extend = function(prop) {
var _super = this.prototype;
// Instantiate a base class (but only create the instance,
// don't run the init constructor)
initializing = true;
var prototype = new this();
initializing = false;
// Copy the properties over onto the new prototype
for (var name in prop) {
// Check if we're overwriting an existing function
prototype[name] = typeof prop[name] == "function" &&
typeof _super[name] == "function" && fnTest.test(prop[name]) ?
(function(name, fn){
return function() {
var tmp = this._super;
// Add a new ._super() method that is the same method
// but on the super-class
this._super = _super[name];
// The method only need to be bound temporarily, so we
// remove it when we're done executing
var ret = fn.apply(this, arguments);
this._super = tmp;
return ret;
};
})(name, prop[name]) :
prop[name];
}
// The dummy class constructor
function Class() {
// All construction is actually done in the init method
if ( !initializing && this.init )
this.init.apply(this, arguments);
}
// Populate our constructed prototype object
Class.prototype = prototype;
// Enforce the constructor to be what we expect
Class.prototype.constructor = Class;
// And make this class extendable
Class.extend = arguments.callee;
return Class;
};
})();
// ###################################################################
// shims
//
// ###################################################################
(function() {
var requestAnimationFrame = window.requestAnimationFrame || window.mozRequestAnimationFrame || window.webkitRequestAnimationFrame || window.msRequestAnimationFrame;
window.requestAnimationFrame = requestAnimationFrame;
})();
(function() {
if (!window.performance.now) {
window.performance.now = (!Date.now) ? function() { return new Date().getTime(); } :
function() { return Date.now(); }
}
})();
// ###################################################################
// Constants
//
// ###################################################################
var IS_CHROME = /Chrome/.test(navigator.userAgent) && /Google Inc/.test(navigator.vendor);
var CANVAS_WIDTH = 640;
var CANVAS_HEIGHT = 640;
var SPRITE_SHEET_SRC = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAEAAAAEACAYAAAADRnAGAAACGUlEQVR42u3aSQ7CMBAEQIsn8P+/hiviAAK8zFIt5QbELiTHmfEYE3L9mZE9AAAAqAVwBQ8AAAD6THY5CgAAAKbfbPX3AQAAYBEEAADAuZrC6UUyfMEEAIBiAN8OePXnAQAAsLcmmKFPAQAAgHMbm+gbr3Sdo/LtcAAAANR6GywPAgBAM4D2JXAAABoBzBjA7AmlOx8AAEAzAOcDAADovTc4vQim6wUCABAYQG8QAADd4dPd2fRVYQAAANQG0B4HAABAawDnAwAA6AXgfAAAALpA2uMAAABwPgAAgPoAM9Ci/R4AAAD2dmqcEQIAIC/AiQGuAAYAAECcRS/a/cJXkUf2AAAAoBaA3iAAALrD+gIAAADY9baX/nwAAADNADwFAADo9YK0e5FMX/UFACA5QPSNEAAAAHKtCekmDAAAAADvBljtfgAAAGgMMGOrunvCy2uCAAAACFU6BwAAwF6AGQPa/XsAAADYB+B8AAAAtU+ItD4OAwAAAFVhAACaA0T7B44/BQAAANALwGMQAAAAADYO8If2+P31AgAAQN0SWbhFDwCAZlXgaO1xAAAA1FngnA8AACAeQPSNEAAAAM4CnC64AAAA4GzN4N9NSfgKEAAAAACszO26X8/X6BYAAAD0Anid8KcLAAAAAAAAAJBnwNEvAAAA9Jns1ygAAAAAAAAAAAAAAAAAAABAQ4COCENERERERERERBrnAa1sJuUVr3rsAAAAAElFTkSuQmCC';
var LEFT_KEY = 37;
var RIGHT_KEY = 39;
var SHOOT_KEY = 88;
var TEXT_BLINK_FREQ = 500;
var PLAYER_CLIP_RECT = { x: 0, y: 204, w: 62, h: 32 };
var ALIEN_BOTTOM_ROW = [ { x: 0, y: 0, w: 51, h: 34 }, { x: 0, y: 102, w: 51, h: 34 }];
var ALIEN_MIDDLE_ROW = [ { x: 0, y: 137, w: 50, h: 33 }, { x: 0, y: 170, w: 50, h: 34 }];
var ALIEN_TOP_ROW = [ { x: 0, y: 68, w: 50, h: 32 }, { x: 0, y: 34, w: 50, h: 32 }];
var ALIEN_X_MARGIN = 40;
var ALIEN_SQUAD_WIDTH = 11 * ALIEN_X_MARGIN;
// ###################################################################
// Utility functions & classes
//
// ###################################################################
function getRandomArbitrary(min, max) {
return Math.random() * (max - min) + min;
}
function getRandomInt(min, max) {
return Math.floor(Math.random() * (max - min + 1)) + min;
}
function clamp(num, min, max) {
return Math.min(Math.max(num, min), max);
}
function valueInRange(value, min, max) {
return (value <= max) && (value >= min);
}
function checkRectCollision(A, B) {
var xOverlap = valueInRange(A.x, B.x, B.x + B.w) ||
valueInRange(B.x, A.x, A.x + A.w);
var yOverlap = valueInRange(A.y, B.y, B.y + B.h) ||
valueInRange(B.y, A.y, A.y + A.h);
return xOverlap && yOverlap;
}
var Point2D = Class.extend({
init: function(x, y) {
this.x = (typeof x === 'undefined') ? 0 : x;
this.y = (typeof y === 'undefined') ? 0 : y;
},
set: function(x, y) {
this.x = x;
this.y = y;
}
});
var Rect = Class.extend({
init: function(x, y, w, h) {
this.x = (typeof x === 'undefined') ? 0 : x;
this.y = (typeof y === 'undefined') ? 0 : y;
this.w = (typeof w === 'undefined') ? 0 : w;
this.h = (typeof h === 'undefined') ? 0 : h;
},
set: function(x, y, w, h) {
this.x = x;
this.y = y;
this.w = w;
this.h = h;
}
});
// ###################################################################
// Globals
//
// ###################################################################
var canvas = null;
var ctx = null;
var spriteSheetImg = null;
var bulletImg = null;
var keyStates = null;
var prevKeyStates = null;
var lastTime = 0;
var player = null;
var aliens = [];
var particleManager = null;
var updateAlienLogic = false;
var alienDirection = -1;
var alienYDown = 0;
var alienCount = 0;
var wave = 1;
var hasGameStarted = false;
// ###################################################################
// Entities
//
// ###################################################################
var BaseSprite = Class.extend({
init: function(img, x, y) {
this.img = img;
this.position = new Point2D(x, y);
this.scale = new Point2D(1, 1);
this.bounds = new Rect(x, y, this.img.width, this.img.height);
this.doLogic = true;
},
update: function(dt) { },
_updateBounds: function() {
this.bounds.set(this.position.x, this.position.y, ~~(0.5 + this.img.width * this.scale.x), ~~(0.5 + this.img.height * this.scale.y));
},
_drawImage: function() {
ctx.drawImage(this.img, this.position.x, this.position.y);
},
draw: function(resized) {
this._updateBounds();
this._drawImage();
}
});
var SheetSprite = BaseSprite.extend({
init: function(sheetImg, clipRect, x, y) {
this._super(sheetImg, x, y);
this.clipRect = clipRect;
this.bounds.set(x, y, this.clipRect.w, this.clipRect.h);
},
update: function(dt) {},
_updateBounds: function() {
var w = ~~(0.5 + this.clipRect.w * this.scale.x);
var h = ~~(0.5 + this.clipRect.h * this.scale.y);
this.bounds.set(this.position.x - w/2, this.position.y - h/2, w, h);
},
_drawImage: function() {
ctx.save();
ctx.transform(this.scale.x, 0, 0, this.scale.y, this.position.x, this.position.y);
ctx.drawImage(this.img, this.clipRect.x, this.clipRect.y, this.clipRect.w, this.clipRect.h, ~~(0.5 + -this.clipRect.w*0.5), ~~(0.5 + -this.clipRect.h*0.5), this.clipRect.w, this.clipRect.h);
ctx.restore();
},
draw: function(resized) {
this._super(resized);
}
});
var Player = SheetSprite.extend({
init: function() {
this._super(spriteSheetImg, PLAYER_CLIP_RECT, CANVAS_WIDTH/2, CANVAS_HEIGHT - 70);
this.scale.set(0.85, 0.85);
this.lives = 3;
this.xVel = 0;
this.bullets = [];
this.bulletDelayAccumulator = 0;
this.score = 0;
},
reset: function() {
this.lives = 3;
this.score = 0;
this.position.set(CANVAS_WIDTH/2, CANVAS_HEIGHT - 70);
},
shoot: function() {
var bullet = new Bullet(this.position.x, this.position.y - this.bounds.h / 2, 1, 1000);
this.bullets.push(bullet);
},
handleInput: function() {
if (isKeyDown(LEFT_KEY)) {
this.xVel = -175;
} else if (isKeyDown(RIGHT_KEY)) {
this.xVel = 175;
} else this.xVel = 0;
if (wasKeyPressed(SHOOT_KEY)) {
if (this.bulletDelayAccumulator > 0.5) {
this.shoot();
this.bulletDelayAccumulator = 0;
}
}
},
updateBullets: function(dt) {
for (var i = this.bullets.length - 1; i >= 0; i--) {
var bullet = this.bullets[i];
if (bullet.alive) {
bullet.update(dt);
} else {
this.bullets.splice(i, 1);
bullet = null;
}
}
},
update: function(dt) {
// update time passed between shots
this.bulletDelayAccumulator += dt;
// apply x vel
this.position.x += this.xVel * dt;
// cap player position in screen bounds
this.position.x = clamp(this.position.x, this.bounds.w/2, CANVAS_WIDTH - this.bounds.w/2);
this.updateBullets(dt);
},
draw: function(resized) {
this._super(resized);
// draw bullets
for (var i = 0, len = this.bullets.length; i < len; i++) {
var bullet = this.bullets[i];
if (bullet.alive) {
bullet.draw(resized);
}
}
}
});
var Bullet = BaseSprite.extend({
init: function(x, y, direction, speed) {
this._super(bulletImg, x, y);
this.direction = direction;
this.speed = speed;
this.alive = true;
},
update: function(dt) {
this.position.y -= (this.speed * this.direction) * dt;
if (this.position.y < 0) {
this.alive = false;
}
},
draw: function(resized) {
this._super(resized);
}
});
var Enemy = SheetSprite.extend({
init: function(clipRects, x, y) {
this._super(spriteSheetImg, clipRects[0], x, y);
this.clipRects = clipRects;
this.scale.set(0.5, 0.5);
this.alive = true;
this.onFirstState = true;
this.stepDelay = 1; // try 2 secs to start with...
this.stepAccumulator = 0;
this.doShoot - false;
this.bullet = null;
},
toggleFrame: function() {
this.onFirstState = !this.onFirstState;
this.clipRect = (this.onFirstState) ? this.clipRects[0] : this.clipRects[1];
},
shoot: function() {
this.bullet = new Bullet(this.position.x, this.position.y + this.bounds.w/2, -1, 500);
},
update: function(dt) {
this.stepAccumulator += dt;
if (this.stepAccumulator >= this.stepDelay) {
if (this.position.x < this.bounds.w/2 + 20 && alienDirection < 0) {
updateAlienLogic = true;
} if (alienDirection === 1 && this.position.x > CANVAS_WIDTH - this.bounds.w/2 - 20) {
updateAlienLogic = true;
}
if (this.position.y > CANVAS_WIDTH - 50) {
reset();
}
var fireTest = Math.floor(Math.random() * (this.stepDelay + 1));
if (getRandomArbitrary(0, 1000) <= 5 * (this.stepDelay + 1)) {
this.doShoot = true;
}
this.position.x += 10 * alienDirection;
this.toggleFrame();
this.stepAccumulator = 0;
}
this.position.y += alienYDown;
if (this.bullet !== null && this.bullet.alive) {
this.bullet.update(dt);
} else {
this.bullet = null;
}
},
draw: function(resized) {
this._super(resized);
if (this.bullet !== null && this.bullet.alive) {
this.bullet.draw(resized);
}
}
});
var ParticleExplosion = Class.extend({
init: function() {
this.particlePool = [];
this.particles = [];
},
draw: function() {
for (var i = this.particles.length - 1; i >= 0; i--) {
var particle = this.particles[i];
particle.moves++;
particle.x += particle.xunits;
particle.y += particle.yunits + (particle.gravity * particle.moves);
particle.life--;
if (particle.life <= 0 ) {
if (this.particlePool.length < 100) {
this.particlePool.push(this.particles.splice(i,1));
} else {
this.particles.splice(i,1);
}
} else {
ctx.globalAlpha = (particle.life)/(particle.maxLife);
ctx.fillStyle = particle.color;
ctx.fillRect(particle.x, particle.y, particle.width, particle.height);
ctx.globalAlpha = 1;
}
}
},
createExplosion: function(x, y, color, number, width, height, spd, grav, lif) {
for (var i =0;i < number;i++) {
var angle = Math.floor(Math.random()*360);
var speed = Math.floor(Math.random()*spd/2) + spd;
var life = Math.floor(Math.random()*lif)+lif/2;
var radians = angle * Math.PI/ 180;
var xunits = Math.cos(radians) * speed;
var yunits = Math.sin(radians) * speed;
if (this.particlePool.length > 0) {
var tempParticle = this.particlePool.pop();
tempParticle.x = x;
tempParticle.y = y;
tempParticle.xunits = xunits;
tempParticle.yunits = yunits;
tempParticle.life = life;
tempParticle.color = color;
tempParticle.width = width;
tempParticle.height = height;
tempParticle.gravity = grav;
tempParticle.moves = 0;
tempParticle.alpha = 1;
tempParticle.maxLife = life;
this.particles.push(tempParticle);
} else {
this.particles.push({x:x,y:y,xunits:xunits,yunits:yunits,life:life,color:color,width:width,height:height,gravity:grav,moves:0,alpha:1, maxLife:life});
}
}
}
});
// ###################################################################
// Initialization functions
//
// ###################################################################
function initCanvas() {
// create our canvas and context
canvas = document.getElementById('game-canvas');
ctx = canvas.getContext('2d');
// turn off image smoothing
setImageSmoothing(false);
// create our main sprite sheet img
spriteSheetImg = new Image();
spriteSheetImg.src = SPRITE_SHEET_SRC;
preDrawImages();
// add event listeners and initially resize
window.addEventListener('resize', resize);
document.addEventListener('keydown', onKeyDown);
document.addEventListener('keyup', onKeyUp);
}
function preDrawImages() {
var canvas = drawIntoCanvas(2, 8, function(ctx) {
ctx.fillStyle = 'white';
ctx.fillRect(0, 0, ctx.canvas.width, ctx.canvas.height);
});
bulletImg = new Image();
bulletImg.src = canvas.toDataURL();
}
function setImageSmoothing(value) {
this.ctx['imageSmoothingEnabled'] = value;
this.ctx['mozImageSmoothingEnabled'] = value;
this.ctx['oImageSmoothingEnabled'] = value;
this.ctx['webkitImageSmoothingEnabled'] = value;
this.ctx['msImageSmoothingEnabled'] = value;
}
function initGame() {
dirtyRects = [];
aliens = [];
player = new Player();
particleManager = new ParticleExplosion();
setupAlienFormation();
drawBottomHud();
}
function setupAlienFormation() {
alienCount = 0;
for (var i = 0, len = 5 * 11; i < len; i++) {
var gridX = (i % 11);
var gridY = Math.floor(i / 11);
var clipRects;
switch (gridY) {
case 0:
case 1: clipRects = ALIEN_BOTTOM_ROW; break;
case 2:
case 3: clipRects = ALIEN_MIDDLE_ROW; break;
case 4: clipRects = ALIEN_TOP_ROW; break;
}
aliens.push(new Enemy(clipRects, (CANVAS_WIDTH/2 - ALIEN_SQUAD_WIDTH/2) + ALIEN_X_MARGIN/2 + gridX * ALIEN_X_MARGIN, CANVAS_HEIGHT/3.25 - gridY * 40));
alienCount++;
}
}
function reset() {
aliens = [];
setupAlienFormation();
player.reset();
}
function init() {
initCanvas();
keyStates = [];
prevKeyStates = [];
resize();
}
// ###################################################################
// Helpful input functions
//
// ###################################################################
function isKeyDown(key) {
return keyStates[key];
}
function wasKeyPressed(key) {
return !prevKeyStates[key] && keyStates[key];
}
// ###################################################################
// Drawing & Update functions
//
// ###################################################################
function updateAliens(dt) {
if (updateAlienLogic) {
updateAlienLogic = false;
alienDirection = -alienDirection;
alienYDown = 25;
}
for (var i = aliens.length - 1; i >= 0; i--) {
var alien = aliens[i];
if (!alien.alive) {
aliens.splice(i, 1);
alien = null;
alienCount--;
if (alienCount < 1) {
wave++;
setupAlienFormation();
}
return;
}
alien.stepDelay = ((alienCount * 20) - (wave * 10)) / 1000;
if (alien.stepDelay <= 0.05) {
alien.stepDelay = 0.05;
}
alien.update(dt);
if (alien.doShoot) {
alien.doShoot = false;
alien.shoot();
}
}
alienYDown = 0;
}
function resolveBulletEnemyCollisions() {
var bullets = player.bullets;
for (var i = 0, len = bullets.length; i < len; i++) {
var bullet = bullets[i];
for (var j = 0, alen = aliens.length; j < alen; j++) {
var alien = aliens[j];
if (checkRectCollision(bullet.bounds, alien.bounds)) {
alien.alive = bullet.alive = false;
particleManager.createExplosion(alien.position.x, alien.position.y, 'white', 70, 5,5,3,.15,50);
player.score += 25;
}
}
}
}
function resolveBulletPlayerCollisions() {
for (var i = 0, len = aliens.length; i < len; i++) {
var alien = aliens[i];
if (alien.bullet !== null && checkRectCollision(alien.bullet.bounds, player.bounds)) {
if (player.lives === 0) {
hasGameStarted = false;
} else {
alien.bullet.alive = false;
particleManager.createExplosion(player.position.x, player.position.y, 'green', 100, 8,8,6,0.001,40);
player.position.set(CANVAS_WIDTH/2, CANVAS_HEIGHT - 70);
player.lives--;
break;
}
}
}
}
function resolveCollisions() {
resolveBulletEnemyCollisions();
resolveBulletPlayerCollisions();
}
function updateGame(dt) {
player.handleInput();
prevKeyStates = keyStates.slice();
player.update(dt);
updateAliens(dt);
resolveCollisions();
}
function drawIntoCanvas(width, height, drawFunc) {
var canvas = document.createElement('canvas');
canvas.width = width;
canvas.height = height;
var ctx = canvas.getContext('2d');
drawFunc(ctx);
return canvas;
}
function fillText(text, x, y, color, fontSize) {
if (typeof color !== 'undefined') ctx.fillStyle = color;
if (typeof fontSize !== 'undefined') ctx.font = fontSize + 'px Play';
ctx.fillText(text, x, y);
}
function fillCenteredText(text, x, y, color, fontSize) {
var metrics = ctx.measureText(text);
fillText(text, x - metrics.width/2, y, color, fontSize);
}
function fillBlinkingText(text, x, y, blinkFreq, color, fontSize) {
if (~~(0.5 + Date.now() / blinkFreq) % 2) {
fillCenteredText(text, x, y, color, fontSize);
}
}
function drawBottomHud() {
ctx.fillStyle = '#02ff12';
ctx.fillRect(0, CANVAS_HEIGHT - 30, CANVAS_WIDTH, 2);
fillText(player.lives + ' x ', 10, CANVAS_HEIGHT - 7.5, 'white', 20);
ctx.drawImage(spriteSheetImg, player.clipRect.x, player.clipRect.y, player.clipRect.w,
player.clipRect.h, 45, CANVAS_HEIGHT - 23, player.clipRect.w * 0.5,
player.clipRect.h * 0.5);
fillText('CREDIT: ', CANVAS_WIDTH - 115, CANVAS_HEIGHT - 7.5);
fillCenteredText('SCORE: ' + player.score, CANVAS_WIDTH/2, 20);
fillBlinkingText('00', CANVAS_WIDTH - 25, CANVAS_HEIGHT - 7.5, TEXT_BLINK_FREQ);
}
function drawAliens(resized) {
for (var i = 0; i < aliens.length; i++) {
var alien = aliens[i];
alien.draw(resized);
}
}
function drawGame(resized) {
player.draw(resized);
drawAliens(resized);
particleManager.draw();
drawBottomHud();
}
function drawStartScreen() {
fillCenteredText("Space Invaders", CANVAS_WIDTH/2, CANVAS_HEIGHT/2.75, '#FFFFFF', 36);
fillBlinkingText("Press enter to play!", CANVAS_WIDTH/2, CANVAS_HEIGHT/2, 500, '#FFFFFF', 36);
}
function animate() {
var now = window.performance.now();
var dt = now - lastTime;
if (dt > 100) dt = 100;
if (wasKeyPressed(13) && !hasGameStarted) {
initGame();
hasGameStarted = true;
}
if (hasGameStarted) {
updateGame(dt / 1000);
}
ctx.fillStyle = 'black';
ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT);
if (hasGameStarted) {
drawGame(false);
} else {
drawStartScreen();
}
lastTime = now;
requestAnimationFrame(animate);
}
// ###################################################################
// Event Listener functions
//
// ###################################################################
function resize() {
var w = window.innerWidth;
var h = window.innerHeight;
// calculate the scale factor to keep a correct aspect ratio
var scaleFactor = Math.min(w / CANVAS_WIDTH, h / CANVAS_HEIGHT);
if (IS_CHROME) {
canvas.width = CANVAS_WIDTH * scaleFactor;
canvas.height = CANVAS_HEIGHT * scaleFactor;
setImageSmoothing(false);
ctx.transform(scaleFactor, 0, 0, scaleFactor, 0, 0);
} else {
// resize the canvas css properties
canvas.style.width = CANVAS_WIDTH * scaleFactor + 'px';
canvas.style.height = CANVAS_HEIGHT * scaleFactor + 'px';
}
}
function onKeyDown(e) {
e.preventDefault();
keyStates[e.keyCode] = true;
}
function onKeyUp(e) {
e.preventDefault();
keyStates[e.keyCode] = false;
}
// ###################################################################
// Start game!
//
// ###################################################################
window.onload = function() {
init();
animate();
};
body, html{
background-color: #EEEEEE;
width: 100%;
height: 100%;
overflow: hidden;
}
canvas {
display: block;
margin: auto;
position :absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
image-rendering: optimizeSpeed;
image-rendering: -moz-crisp-edges;
image-rendering: -webkit-optimize-contrast;
image-rendering: optimize-contrast
}
@jakeisnt
Copy link

jakeisnt commented Jul 4, 2021

@afgs99, I ended changing the variable SHOOT_KEY to 32 - this allowed me to use 'Space' instead of 'x' for shooting, which I found a bit more comfortable.

Great work with this game by the way!

@insipx
Copy link
Author

insipx commented Jul 5, 2021

@jakeisnt cool!

Just wanted to make it clear: This game is not mine, all credit goes to John Resig http://ejohn.org/

I don't remember why I copied it into a gist here, or how this is even discoverable because it's named 'index.html', but just wanted to give credit where credit is due!

Good to hear that people enjoy it

@jakeisnt
Copy link

jakeisnt commented Jul 5, 2021

Ah thanks! This was the first search result on DuckDuckGo for "space invaders game html canvas". Not sure why it showed up first!

@MATEU047
Copy link

I copied all to my visual studio and it doesn't work

@mrfrase3
Copy link

I wanted something that I could throw into an existing project as an easter egg, this gave a good start, but there was a sever lack of modern JS features (like classes) and obviously typing.

So I re-wrote this into an import-able package, you can find it here

Thanks for making this discoverable in the first place. 👍

@insipx
Copy link
Author

insipx commented Feb 19, 2024

@mrfrase3 cool! Nice job

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment