diff --git a/web-vanilla/index.html b/web-vanilla/index.html
new file mode 100644
index 0000000..af90b37
--- /dev/null
+++ b/web-vanilla/index.html
@@ -0,0 +1,15 @@
+
+
+
+
JavaScript
+
+
+
+
+HTML/JavaScript mini-projects
+
+
\ No newline at end of file
diff --git a/web-vanilla/mania/mania.html b/web-vanilla/mania/mania.html
new file mode 100644
index 0000000..27b87cf
--- /dev/null
+++ b/web-vanilla/mania/mania.html
@@ -0,0 +1,36 @@
+
+
+
+osu!mania playfield
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/web-vanilla/mania/mania.js b/web-vanilla/mania/mania.js
new file mode 100644
index 0000000..2c37d6e
--- /dev/null
+++ b/web-vanilla/mania/mania.js
@@ -0,0 +1,112 @@
+var canvas;
+var ctx;
+
+/* Base resolution in osu! skins used for scaling */
+const internal_width = 640;
+const internal_height = 480;
+
+/* Updates per second */
+const framerate = 60;
+
+/* Colors for playfield */
+const colors = {
+ playfield: "#000",
+ lane_separator: "#222",
+ hitposition: "#AA0",
+ note: "#FFF"
+};
+
+/* Values retrieved from DOM */
+var options = {
+ columnstart: 0,
+ columnwidth: 0,
+ hitposition: 0,
+ scrollspeed: 0,
+ noteratio: 0,
+ keycount: 0
+}
+
+/* Clears the canvas */
+function clear() {
+ ctx.clearRect(0, 0, canvas.width, canvas.height);
+}
+
+/* Draws a rectangle */
+function rect(x, y, w, h, color) {
+ ctx.fillStyle = color;
+ ctx.fillRect(x, y, w, h);
+}
+
+/* Draws a string */
+function text(string, x, y, color) {
+ ctx.fillStyle = color;
+ ctx.fillText(string, x, y);
+}
+
+/* Translates osu! coordinates to screen coordinates */
+function translate_pos(x, y) {
+ return {
+ x: Math.floor(x / internal_width * canvas.width),
+ y: Math.floor(y / internal_height * canvas.height)
+ }
+}
+
+function draw() {
+ clear()
+
+ const columnstart = translate_pos(options.columnstart, 0);
+ const columnwidth = translate_pos(options.columnwidth, 0);
+ const hitposition = translate_pos(options.columnstart, options.hitposition);
+
+ const playfield_width = columnwidth.x * options.keycount;
+ const note_height = columnwidth.x * options.noteratio;
+
+ // playfield background
+ rect(columnstart.x, 0, playfield_width, canvas.height, colors.playfield);
+
+ // lane separators
+ for (var i = 0; i <= options.keycount; i++) {
+ rect(columnstart.x + i * columnwidth.x, 0, 1, canvas.height, colors.lane_separator);
+ }
+
+ // hitpos
+ rect(hitposition.x, hitposition.y, playfield_width, 5, colors.hitposition);
+
+ // notes
+ let y_pos = hitposition.y - note_height;
+ while (y_pos > 0) {
+ for (let i = 0; i < options.keycount; ++i) {
+ rect (
+ columnstart.x + i * columnwidth.x,
+ y_pos,
+ columnwidth.x, note_height, colors.note
+ );
+
+ // completely inaccurate but i cba to simulate the whole playfield
+ y_pos -= options.scrollspeed * 3;
+ }
+ }
+}
+
+window.onload = function () {
+ create_prologue();
+
+ canvas = document.getElementById("playfield");
+ ctx = canvas.getContext("2d");
+
+ ctx.font = "15px Arial";
+ ctx.textBaseline = "hanging";
+
+ for (let input of document.getElementsByTagName("input")) {
+ input.onchange = function () {
+ options[this.id] = this.value;
+ document.getElementById(this.id + "_out").innerText = this.value;
+ };
+
+ input.onchange();
+ }
+
+ setInterval(function () {
+ draw();
+ }, 1000 / framerate);
+};
diff --git a/web-vanilla/reaction/area.css b/web-vanilla/reaction/area.css
new file mode 100644
index 0000000..cf02916
--- /dev/null
+++ b/web-vanilla/reaction/area.css
@@ -0,0 +1,9 @@
+#area {
+ height: 400px;
+ background-color: #444;
+ color: #FFF;
+ font-size: 24px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
\ No newline at end of file
diff --git a/web-vanilla/reaction/reaction.html b/web-vanilla/reaction/reaction.html
new file mode 100644
index 0000000..8e47f5f
--- /dev/null
+++ b/web-vanilla/reaction/reaction.html
@@ -0,0 +1,14 @@
+
+
+
+Reaction time test
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/web-vanilla/reaction/reaction.js b/web-vanilla/reaction/reaction.js
new file mode 100644
index 0000000..3648df9
--- /dev/null
+++ b/web-vanilla/reaction/reaction.js
@@ -0,0 +1,88 @@
+
+const states = {
+ START: 'start',
+ TIMEOUT: 'timeout',
+ READY: 'ready'
+}
+
+const colors = {
+ TIMEOUT: 'crimson',
+ START: 'dodgerblue',
+ READY: 'limegreen'
+}
+
+var state = states.START;
+var times = [];
+
+var ready_time;
+var ready_id; // timeout id
+
+const min_time = 1000;
+const max_time = 3000;
+
+function handle_click() {
+ switch (state) {
+ // click received on start, activate timer and set state to timeout
+ case states.START: {
+
+ // this is a hack to access thisptr from setTimeout
+ let self = this;
+
+ ready_id = setTimeout(function () {
+ ready_time = Date.now();
+
+ self.style["background-color"] = colors.READY;
+ self.innerText = "Click now!";
+
+ state = states.READY;
+ }, min_time + (Math.random() * (max_time - min_time)));
+
+ this.style["background-color"] = colors.TIMEOUT;
+ this.innerText = "Get ready!";
+
+ state = states.TIMEOUT;
+ } break;
+
+ // click received during timeout, clear timeout and reset
+ case states.TIMEOUT: {
+ clearTimeout(ready_id);
+
+ this.style["background-color"] = colors.START;
+ this.innerText = "Too early!";
+
+ state = states.START;
+ } break;
+
+ // click received after timeout, calculate score and reset
+ case states.READY: {
+ let time = (Date.now() - ready_time);
+ times.push(time);
+
+ let average = times.reduce((a, b) => a + b) / times.length;
+
+ // update average text
+ document.getElementById("average").innerText = "Average: " + average + "ms";
+
+ // create and add time listing
+ var li = document.createElement("li");
+ li.appendChild(document.createTextNode(time + "ms"));
+ document.getElementById("times").appendChild(li);
+
+ this.style["background-color"] = colors.START;
+ this.innerText = time + "ms";
+
+ state = states.START;
+ } break;
+
+ default:
+ break
+ }
+}
+
+window.onload = function () {
+ create_prologue();
+
+ let area = document.getElementById("area");
+ area.innerText = "Click here to begin!";
+ area.onclick = handle_click;
+}
diff --git a/web-vanilla/shared.js b/web-vanilla/shared.js
new file mode 100644
index 0000000..8c6ff64
--- /dev/null
+++ b/web-vanilla/shared.js
@@ -0,0 +1,13 @@
+
+function create_prologue() {
+ let back_to_index = document.createElement("a");
+ back_to_index.setAttribute("href", "../index.html");
+ back_to_index.innerHTML = "Back to index";
+
+ let title = document.createElement("h1");
+ title.innerText = document.title;
+
+ let body = document.getElementsByTagName("body")[0];
+ body.insertBefore(title, body.firstChild);
+ body.insertBefore(back_to_index, body.firstChild);
+}
diff --git a/web-vanilla/style.css b/web-vanilla/style.css
new file mode 100644
index 0000000..270eb97
--- /dev/null
+++ b/web-vanilla/style.css
@@ -0,0 +1,13 @@
+body {
+ color: #444;
+ font: 18px/1.6 Arial, Helvetica, sans-serif;
+ margin: 40px auto;
+ max-width: 650px;
+ padding: 0 10px;
+}
+
+h1,
+h2,
+h3 {
+ line-height: 1.2
+}
diff --git a/web-vanilla/trill/area.css b/web-vanilla/trill/area.css
new file mode 100644
index 0000000..cf02916
--- /dev/null
+++ b/web-vanilla/trill/area.css
@@ -0,0 +1,9 @@
+#area {
+ height: 400px;
+ background-color: #444;
+ color: #FFF;
+ font-size: 24px;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+}
\ No newline at end of file
diff --git a/web-vanilla/trill/trill.html b/web-vanilla/trill/trill.html
new file mode 100644
index 0000000..424a55c
--- /dev/null
+++ b/web-vanilla/trill/trill.html
@@ -0,0 +1,14 @@
+
+
+
+Trill test
+
+
+
+
+
+
+
+
+
+
\ No newline at end of file
diff --git a/web-vanilla/trill/trill.js b/web-vanilla/trill/trill.js
new file mode 100644
index 0000000..e85b5e4
--- /dev/null
+++ b/web-vanilla/trill/trill.js
@@ -0,0 +1,102 @@
+
+const states = {
+ GETKEYS: 'getkeys',
+ TRILLING: 'trilling',
+ READY: 'ready',
+ RESULT: 'result'
+}
+
+const colors = {
+ GETKEYS: 'gray',
+ TRILLING: 'crimson',
+ READY: 'darkgray',
+ RESULT: 'dodgerblue'
+}
+
+var state = states.GETKEYS;
+var times = []
+
+const max_count = 20;
+
+var key1, key2;
+var time_start;
+var counter = 0;
+
+function handle_press(e) {
+ switch (state) {
+ case states.GETKEYS: {
+ if (!key1) {
+ key1 = e.code;
+
+ area.style["background-color"] = colors.GETKEYS;
+ area.innerText = "Press 2nd key!";
+ } else {
+ key2 = e.code;
+ state = states.READY;
+
+ area.style["background-color"] = colors.READY;
+ area.innerText = "Test will start on first keypress.";
+ }
+ } break;
+
+ case states.READY: {
+ if (e.code == key1 || e.code == key2) {
+ state = states.TRILLING;
+
+ time_start = Date.now();
+
+ area.style["background-color"] = colors.TRILLING;
+ handle_press(e); // update couter/text for first keypress
+ }
+ } break;
+
+ case states.TRILLING: {
+ if (e.code == key1 || e.code == key2) {
+ counter++;
+ }
+
+ area.innerText = counter + "/" + max_count;
+
+ if (counter == max_count) {
+ state = states.RESULT;
+
+ counter = 0;
+
+ let time = parseInt((max_count / ((Date.now() - time_start) / 1000) * 60 / 4));
+ times.push(time);
+
+ let average = times.reduce((a, b) => a + b) / times.length;
+
+ document.getElementById("average").innerText = "Average: " + average + "bpm";
+
+ var li = document.createElement("li");
+ li.appendChild(document.createTextNode(time + "bpm"));
+ document.getElementById("times").appendChild(li);
+
+ area.style["background-color"] = colors.RESULT;
+ area.innerText = time + "bpm" + '\n(press space to try again)';
+ }
+ } break;
+
+ case states.RESULT: {
+ if (e.code == "Space") {
+ state = states.READY;
+
+ area.style["background-color"] = colors.READY;
+ area.innerText = "Test will start on first keypress.";
+ }
+ } break;
+
+ default:
+ break
+ }
+}
+
+window.onload = function () {
+ create_prologue();
+
+ area = document.getElementById("area");
+ area.innerText = "Press 1st key!";
+
+ document.addEventListener('keydown', handle_press);
+}