diff --git a/counters/Mania by knvi/css/style.css b/counters/Mania by knvi/css/style.css
new file mode 100644
index 0000000..3e69d67
--- /dev/null
+++ b/counters/Mania by knvi/css/style.css
@@ -0,0 +1,79 @@
+* {
+ margin: 0;
+ padding: 0;
+ box-sizing: border-box;
+}
+
+body {
+ font-family: 'Arial', 'Helvetica', sans-serif;
+ background: transparent;
+ overflow: hidden;
+}
+
+.hit-container {
+ display: inline-block;
+ background: rgba(20, 20, 30, 0.85);
+ padding: 9px 15px;
+ border-radius: 3px;
+ min-width: 150px;
+ visibility: hidden;
+ opacity: 0;
+ transition: opacity 0.3s ease, visibility 0.3s ease;
+}
+
+.hit-row {
+ display: flex;
+ justify-content: space-between;
+ align-items: center;
+ padding: 3px 0;
+ gap: 60px;
+ line-height: 1.6;
+ transition: all 0.1s ease;
+}
+
+.label {
+ font-weight: bold;
+ font-size: 18px;
+ letter-spacing: 0px;
+ text-shadow: 0px 0px 4px rgba(0, 0, 0, 1);
+ min-width: 33px;
+ text-align: left;
+}
+
+.value {
+ font-weight: bold;
+ font-size: 18px;
+ text-shadow: 0px 0px 4px rgba(0, 0, 0, 1);
+ text-align: right;
+ min-width: 45px;
+}
+
+.perfect .label,
+.perfect .value {
+ color: #00FFFF;
+}
+
+.great .label,
+.great .value {
+ color: #FFFF00;
+}
+
+.good .label,
+.good .value {
+ color: #00FF00;
+}
+
+.ok .label,
+.ok .value {
+ color: #00BFFF;
+}
+
+.meh .label,
+.meh .value {
+ color: #FF00FF;
+}
+
+.miss .label,
+.miss .value {
+ color: #FF0000;
+}
diff --git a/counters/Mania by knvi/index.html b/counters/Mania by knvi/index.html
new file mode 100644
index 0000000..fc2e55f
--- /dev/null
+++ b/counters/Mania by knvi/index.html
@@ -0,0 +1,40 @@
+
+
+
+
+
+ Mania Counter
+
+
+
+
+
+ MA
+ 0
+
+
+ PR
+ 0
+
+
+ GR
+ 0
+
+
+ GD
+ 0
+
+
+ BD
+ 0
+
+
+ MS
+ 0
+
+
+
+
+
+
+
diff --git a/counters/Mania by knvi/js/script.js b/counters/Mania by knvi/js/script.js
new file mode 100644
index 0000000..5ed8be6
--- /dev/null
+++ b/counters/Mania by knvi/js/script.js
@@ -0,0 +1,167 @@
+let socket;
+let reconnectInterval;
+
+const GameState = {
+ menu: 0,
+ edit: 1,
+ play: 2,
+ exit: 3,
+ selectEdit: 4,
+ selectPlay: 5,
+ selectDrawings: 6,
+ resultScreen: 7,
+ update: 8,
+ busy: 9,
+ unknown: 10,
+ lobby: 11,
+ matchSetup: 12,
+ selectMulti: 13
+};
+
+const elements = {
+ perfect: document.getElementById('perfect'),
+ great: document.getElementById('great'),
+ good: document.getElementById('good'),
+ ok: document.getElementById('ok'),
+ meh: document.getElementById('meh'),
+ miss: document.getElementById('miss')
+};
+
+const container = document.querySelector('.hit-container');
+
+let previousHits = {
+ perfect: 0,
+ great: 0,
+ good: 0,
+ ok: 0,
+ meh: 0,
+ miss: 0
+};
+
+let isInGameplay = false;
+
+function connectWebSocket() {
+ socket = new WebSocket('ws://127.0.0.1:24050/websocket/v2');
+
+ socket.onopen = () => {
+ console.log('connected');
+ if (reconnectInterval) {
+ clearInterval(reconnectInterval);
+ reconnectInterval = null;
+ }
+ };
+
+ socket.onmessage = (event) => {
+ try {
+ const data = JSON.parse(event.data);
+ updateHitCounter(data);
+ } catch (error) {
+ console.error('error parsing data:', error);
+ }
+ };
+
+ socket.onerror = (error) => {
+ console.error('ws error:', error);
+ };
+
+ socket.onclose = () => {
+ console.log('disconnected');
+ hideCounter();
+ if (!reconnectInterval) {
+ reconnectInterval = setInterval(() => {
+ connectWebSocket();
+ }, 3000);
+ }
+ };
+}
+
+function updateHitCounter(data) {
+ const statusNumber = data.state?.number;
+
+ const isPlaying = statusNumber === GameState.play;
+
+ if (!isPlaying || !data.play || !data.play.hits) {
+ if (isInGameplay) {
+ hideCounter();
+ resetCounter();
+ isInGameplay = false;
+ }
+ return;
+ }
+
+ if (!isInGameplay) {
+ showCounter();
+ isInGameplay = true;
+ }
+
+ const hits = data.play.hits;
+
+ const currentHits = {
+ perfect: Number(hits.geki) || 0,
+ great: Number(hits['300']) || 0,
+ good: Number(hits.katu) || 0,
+ ok: Number(hits['100']) || 0,
+ meh: Number(hits['50']) || 0,
+ miss: Number(hits['0']) || 0
+ };
+
+ console.log('Parsed hits:', currentHits);
+
+ updateValue('perfect', currentHits.perfect);
+ updateValue('great', currentHits.great);
+ updateValue('good', currentHits.good);
+ updateValue('ok', currentHits.ok);
+ updateValue('meh', currentHits.meh);
+ updateValue('miss', currentHits.miss);
+
+ previousHits = currentHits;
+}
+
+function updateValue(key, value) {
+ const element = elements[key];
+ if (!element) {
+ console.warn(`Element not found for key: ${key}`);
+ return;
+ }
+
+ const oldValue = parseInt(element.textContent) || 0;
+
+ element.textContent = value.toString();
+}
+
+function showCounter() {
+ container.style.opacity = '1';
+ container.style.visibility = 'visible';
+}
+
+function hideCounter() {
+ container.style.opacity = '0';
+ container.style.visibility = 'hidden';
+}
+
+function resetCounter() {
+ Object.keys(elements).forEach(key => {
+ if (elements[key]) {
+ elements[key].textContent = '0';
+ }
+ });
+
+ previousHits = {
+ perfect: 0,
+ great: 0,
+ good: 0,
+ ok: 0,
+ meh: 0,
+ miss: 0
+ };
+}
+
+connectWebSocket();
+
+hideCounter();
+
+document.addEventListener('visibilitychange', () => {
+ if (!document.hidden && socket.readyState !== WebSocket.OPEN) {
+ connectWebSocket();
+ }
+});
diff --git a/counters/Mania by knvi/metadata.txt b/counters/Mania by knvi/metadata.txt
new file mode 100644
index 0000000..174659b
--- /dev/null
+++ b/counters/Mania by knvi/metadata.txt
@@ -0,0 +1,8 @@
+Usecase: in-game, obs-overlay
+Name: Mania
+Version: 1.0.0
+Author: knvi
+CompatibleWith: tosu
+Resolution: 169x224
+authorLinks: https://github.com/knvi
+Notes: just a simple mania judgement counter (inspired by etterna)