diff --git a/main.html b/main.html
new file mode 100644
index 0000000..e124205
--- /dev/null
+++ b/main.html
@@ -0,0 +1,33 @@
+
+
+
+
+ snej
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/main.js b/main.js
new file mode 100644
index 0000000..d982b64
--- /dev/null
+++ b/main.js
@@ -0,0 +1,254 @@
+class Input {
+ constructor() {
+ this.left = false;
+ this.right = false;
+ this.up = false;
+ this.down = false;
+ this.a = false;
+ this.b = false;
+ this.x = false;
+ this.y = false;
+ this.l = false;
+ this.r = false;
+ this.select = false;
+ this.start = false;
+
+ window.addEventListener('gamepadconnected', this.gamepadConnected);
+ window.addEventListener('gamepaddisconnected', this.gamepadDisconnected);
+ }
+
+ update() {
+ // TODO: have a config screen instead of hard-coding the 8Bitdo SNES30 pad.
+ // TODO: handle connects / disconnects more correctly.
+ const gamepad = navigator.getGamepads()[0];
+ if (gamepad == null || !gamepad.connected || gamepad.axes.length < 2 ||
+ gamepad.buttons.length < 12) {
+ return;
+ }
+
+ this.left = gamepad.axes[0] < 0;
+ this.right = gamepad.axes[0] > 0;
+ this.up = gamepad.axes[1] < 0;
+ this.down = gamepad.axes[1] > 0;
+ this.a = gamepad.buttons[0].pressed;
+ this.b = gamepad.buttons[1].pressed;
+ this.x = gamepad.buttons[3].pressed;
+ this.y = gamepad.buttons[4].pressed;
+ this.l = gamepad.buttons[6].pressed;
+ this.r = gamepad.buttons[7].pressed;
+ this.select = gamepad.buttons[10].pressed;
+ this.start = gamepad.buttons[11].pressed;
+ debug(this.toString());
+ }
+
+ gamepadConnected(e) {
+ debug('gamepad connected! :)');
+ console.log('gamepad connected @ index %d: %d buttons, %d axes\n[%s]',
+ e.gamepad.index, e.gamepad.buttons.length, e.gamepad.axes.length,
+ e.gamepad.id);
+ }
+
+ gamepadDisconnected(e) {
+ debug('gamepad disconnected :(');
+ console.log('gamepad disconnected @ index %d:\n[%s]', e.gamepad.index,
+ e.gamepad.id);
+ }
+
+ toString() {
+ let result = '';
+
+ if (this.up) {
+ result += '^';
+ } else if (this.down) {
+ result += 'v';
+ } else {
+ result += '-';
+ }
+
+ if (this.left) {
+ result += '<';
+ } else if (this.right) {
+ result += '>';
+ } else {
+ result += '-';
+ }
+
+ result += ' ';
+
+ if (this.a) {
+ result += 'A';
+ }
+ if (this.b) {
+ result += 'B';
+ }
+ if (this.x) {
+ result += 'X';
+ }
+ if (this.y) {
+ result += 'Y';
+ }
+ if (this.l) {
+ result += 'L';
+ }
+ if (this.r) {
+ result += 'R';
+ }
+ if (this.select) {
+ result += 's';
+ }
+ if (this.start) {
+ result += 'S';
+ }
+ return result;
+ }
+}
+
+const input = new Input();
+
+class Graphics {
+ constructor(canvas) {
+ this.canvas_ = canvas;
+ this.ctx_ = canvas.getContext('2d');
+ }
+
+ get width() {
+ return this.canvas_.width;
+ }
+
+ get height() {
+ return this.canvas_.height;
+ }
+
+ fill(color) {
+ this.ctx_.fillStyle = color;
+ this.ctx_.beginPath();
+ this.ctx_.rect(0, 0, this.canvas_.width, this.canvas_.height);
+ this.ctx_.fill();
+ this.ctx_.closePath();
+ }
+
+ circle(x, y, radius, color) {
+ this.ctx_.fillStyle = color;
+ this.ctx_.beginPath();
+ this.ctx_.arc(x, y, radius, 0, 2 * Math.PI)
+ this.ctx_.fill();
+ this.ctx_.closePath();
+ }
+
+ // TODO: replace with custom sprite-based text rendering.
+ text(string, x, y, size, color) {
+ this.ctx_.imageSmoothingEnabled = false;
+ this.ctx_.fillStyle = color;
+ this.ctx_.font = '' + size + 'px monospace';
+ this.ctx_.fillText(string, x, y);
+ }
+}
+
+class FpsCounter {
+ constructor() {
+ this.fps = 0;
+ this.frameTimes_ = new Array(60);
+ this.idx_ = 0;
+ }
+
+ update(timestampMs) {
+ if (this.frameTimes_[this.idx_]) {
+ const timeElapsed = (timestampMs - this.frameTimes_[this.idx_]) / 1000;
+ this.fps = this.frameTimes_.length / timeElapsed;
+ }
+ this.frameTimes_[this.idx_] = timestampMs;
+ this.idx_++;
+ if (this.idx_ == this.frameTimes_.length) {
+ this.idx_ = 0;
+ }
+ }
+
+ draw(gfx) {
+ gfx.text('FPS: ' + Math.round(this.fps), 8, 16, 16, 'yellow');
+ }
+}
+
+class World {
+ constructor() {
+ this.state_ = null;
+ this.fpsCounter_ = new FpsCounter();
+ this.input_ = new Input();
+ this.gamepadRenderer_ = new GamepadRenderer();
+ }
+
+ update(timestampMs) {
+ this.fpsCounter_.update(timestampMs);
+ this.input_.update();
+ }
+
+ draw(gfx) {
+ this.gamepadRenderer_.draw(gfx, this.input_);
+ this.fpsCounter_.draw(gfx);
+ }
+}
+
+class GamepadRenderer {
+ draw(gfx, input) {
+ const centerX = gfx.width / 2;
+ const centerY = gfx.height / 2;
+
+ gfx.fill('black');
+
+ // Select & Start
+ gfx.circle(centerX + 12, centerY, 8, input.start ? 'cyan' : 'grey');
+ gfx.circle(centerX - 12, centerY, 8, input.select ? 'cyan' : 'grey');
+
+ // Y X B A
+ gfx.circle(centerX + 48, centerY, 8, input.y ? 'cyan' : 'grey');
+ gfx.circle(centerX + 64, centerY - 16, 8, input.x ? 'cyan' : 'grey');
+ gfx.circle(centerX + 64, centerY + 16, 8, input.b ? 'cyan' : 'grey');
+ gfx.circle(centerX + 80, centerY, 8, input.a ? 'cyan' : 'grey');
+
+ // dpad
+ gfx.circle(centerX - 48, centerY, 8, input.right ? 'cyan' : 'grey');
+ gfx.circle(centerX - 64, centerY - 16, 8, input.up ? 'cyan' : 'grey');
+ gfx.circle(centerX - 64, centerY + 16, 8, input.down ? 'cyan' : 'grey');
+ gfx.circle(centerX - 80, centerY, 8, input.left ? 'cyan' : 'grey');
+
+ // L & R
+ gfx.circle(centerX + 30, centerY - 32, 8, input.r ? 'cyan' : 'grey');
+ gfx.circle(centerX - 30, centerY - 32, 8, input.l ? 'cyan' : 'grey');
+ }
+}
+
+function debug(message) {
+ const debugDiv = document.getElementById('debug');
+ debugDiv.innerText = message;
+}
+
+function loop(world, gfx) {
+ return timestampMs => {
+ world.update(timestampMs);
+ world.draw(gfx);
+ window.requestAnimationFrame(loop(world, gfx));
+ };
+}
+
+function setCanvasScale(scale) {
+ const snesWidth = 256;
+ const snesHeight = 224;
+ const canvas = document.getElementById('canvas');
+ canvas.style.width = '' + snesWidth * scale + 'px';
+ canvas.style.height = '' + snesHeight * scale + 'px';
+}
+
+function init() {
+ const world = new World();
+ const gfx = new Graphics(document.getElementById('canvas'));
+
+ document.getElementById('1x').onclick = () => setCanvasScale(1);
+ document.getElementById('2x').onclick = () => setCanvasScale(2);
+ document.getElementById('3x').onclick = () => setCanvasScale(3);
+ document.getElementById('4x').onclick = () => setCanvasScale(4);
+ document.getElementById('5x').onclick = () => setCanvasScale(5);
+ setCanvasScale(4);
+ window.requestAnimationFrame(loop(world, gfx));
+ debug('initialized!');
+}
+
+init();