diff --git a/haugebot/asgi.py b/haugebot/asgi.py
index abb3541ea3f4de2dd0b3ebbca312a74d75ad794f..3d887d50d62ee1c184abd6f82595195fbe146d47 100644
--- a/haugebot/asgi.py
+++ b/haugebot/asgi.py
@@ -9,8 +9,19 @@ https://docs.djangoproject.com/en/3.1/howto/deployment/asgi/
 
 import os
 
+from channels.auth import AuthMiddlewareStack
+from channels.routing import ProtocolTypeRouter, URLRouter
 from django.core.asgi import get_asgi_application
 
+import haugebot.routing
+
 os.environ.setdefault('DJANGO_SETTINGS_MODULE', 'haugebot.settings')
 
-application = get_asgi_application()
+application = ProtocolTypeRouter({
+    "http": get_asgi_application(),
+    "websocket": AuthMiddlewareStack(
+        URLRouter(
+            haugebot.routing.websocket_urlpatterns
+        )
+    )
+})
diff --git a/haugebot/consumers.py b/haugebot/consumers.py
new file mode 100644
index 0000000000000000000000000000000000000000..eaa390eea21f4dff4e67e675c905b699539f2cbe
--- /dev/null
+++ b/haugebot/consumers.py
@@ -0,0 +1,102 @@
+import json
+import os
+
+from django.contrib.sessions.models import Session
+from asgiref.sync import async_to_sync
+from channels.generic.websocket import WebsocketConsumer
+from wordcloud import WordCloud
+
+secret = os.getenv("WORDCLOUD_SECRET")
+words = {}
+permitted = []
+uuid = None
+
+
+def make_image(uuid, words, permitted):
+    permitted_words = {word: qty for (word, qty) in words.items() if word in permitted}
+    if len(permitted_words) > 0:
+        wc = WordCloud(background_color="black", width=1920, height=1080, min_font_size=1, relative_scaling=1)
+        wc.generate_from_frequencies(permitted_words)
+
+        wc.to_file(f"media/{uuid}.png")
+
+
+def verify_session_key(session_key):
+    return len(list(Session.objects.filter(session_key=session_key))) > 0
+
+
+class WordCloudConsumer(WebsocketConsumer):
+    def __init__(self, *args, **kwargs):
+        super().__init__(*args, **kwargs)
+
+    def connect(self):
+        async_to_sync(self.channel_layer.group_add)(
+            "wordcloud",
+            self.channel_name
+        )
+
+        self.accept()
+
+    def disconnect(self, code):
+        async_to_sync(self.channel_layer.group_discard)(
+            "wordcloud",
+            self.channel_name
+        )
+
+    def receive(self, text_data=None, bytes_data=None):
+        data = json.loads(text_data)
+        message_type = data.get("type")
+
+        if message_type == "word_update":
+            self.word_update(data)
+        elif message_type == "permit":
+            self.permit(data)
+        elif message_type == "deny":
+            self.deny(data)
+
+        async_to_sync(self.channel_layer.group_send)(
+            "wordcloud",
+            {
+                "type": "broadcast",
+                "data": data
+            }
+        )
+
+    def word_update(self, data):
+        global uuid, words, permitted
+
+        if data.get("secret") != secret:
+            return
+
+        if uuid != data.get("uuid"):
+            uuid = data.get("uuid")
+            words = {}
+            permitted = []
+
+        if words != data.get("words"):
+            words = data.get("words")
+            make_image(uuid, words, permitted)
+
+    def permit(self, data):
+        if not verify_session_key(data.get("session_key")):
+            return
+        word = data.get("word")
+        if word not in permitted:
+            permitted.append(word)
+            make_image(uuid, words, permitted)
+
+    def deny(self, data):
+        if not verify_session_key(data.get("session_key")):
+            return
+        word = data.get("word")
+        if word in permitted:
+            permitted.remove(word)
+            make_image(uuid, words, permitted)
+
+    def broadcast(self, event):
+        message = {
+            "uuid": uuid,
+            "words": words,
+            "permitted": permitted
+        }
+        self.send(text_data=json.dumps(message))
diff --git a/haugebot/routing.py b/haugebot/routing.py
new file mode 100644
index 0000000000000000000000000000000000000000..558cff4c7dc6d93ce05a0f5de61759232a3e87da
--- /dev/null
+++ b/haugebot/routing.py
@@ -0,0 +1,7 @@
+from django.urls import re_path
+
+from . import consumers
+
+websocket_urlpatterns = [
+    re_path(r'ws/wordcloud/$', consumers.WordCloudConsumer.as_asgi()),
+]
\ No newline at end of file
diff --git a/haugebot/settings.py b/haugebot/settings.py
index 0270863c0c20e4bc4174ab13bb9b7a29b12098ea..92ecbb4898af3aa0149057eb494ae4dd51d40bd4 100644
--- a/haugebot/settings.py
+++ b/haugebot/settings.py
@@ -44,6 +44,7 @@ INSTALLED_APPS = [
     'django.contrib.sessions',
     'django.contrib.messages',
     'django.contrib.staticfiles',
+    'channels',
     'haugebot_web',
 ]
 
@@ -76,7 +77,15 @@ TEMPLATES = [
     },
 ]
 
-WSGI_APPLICATION = 'haugebot.wsgi.application'
+ASGI_APPLICATION = 'haugebot.asgi.application'
+CHANNEL_LAYERS = {
+    'default': {
+        'BACKEND': 'channels_redis.core.RedisChannelLayer',
+        'CONFIG': {
+            'hosts': [('127.0.0.1', 6379)],
+        }
+    },
+}
 
 # Database
 # https://docs.djangoproject.com/en/3.1/ref/settings/#databases
@@ -124,3 +133,6 @@ USE_TZ = True
 
 STATIC_URL = '/static/'
 STATIC_ROOT = 'static/'
+
+MEDIA_URL = '/media/'
+MEDIA_ROOT = 'media/'
diff --git a/haugebot/urls.py b/haugebot/urls.py
index 971b78fc9843ad7affc843155c3125f26fe7ac66..225e361881688573ffe24f5d14405bcb1d8abe16 100644
--- a/haugebot/urls.py
+++ b/haugebot/urls.py
@@ -15,6 +15,8 @@ Including another URLconf
 """
 from django.contrib import admin
 from django.urls import path
+from django.conf import settings
+from django.conf.urls.static import static
 
 from haugebot_web import views
 
@@ -26,4 +28,10 @@ urlpatterns = [
     path('login/redirect/', views.login_redirect, name="login_redirect"),
     path('wusstest_du_schon/', views.wusstest_du_schon, name="wusstest_du_schon"),
     path('wusstest_du_schon/remove/<int:id>', views.wusstest_du_schon_remove, name="wusstest_du_schon_remove"),
+    path('wordcloud/', views.wordcloud, name="wordcloud"),
+    path('wordcloud/live/<str:id>', views.wordcloud_live, name="wordcloud_live")
 ]
+
+if settings.DEBUG:
+    urlpatterns += static(settings.MEDIA_URL,
+                          document_root=settings.MEDIA_ROOT)
\ No newline at end of file
diff --git a/haugebot_twitch/haugebot.py b/haugebot_twitch/haugebot.py
index 26b59e1f43ccd9ea26de41174392e36095e605e4..b5fb0ece447c46fa06594ff86c9b219574aaf5a6 100644
--- a/haugebot_twitch/haugebot.py
+++ b/haugebot_twitch/haugebot.py
@@ -8,6 +8,7 @@ from twitchio import Channel, Message
 
 from vote_cog import VoteCog
 from wusstest_du_schon import WusstestDuSchon
+from wordcloud import Wordcloud
 
 
 class HaugeBot(Bot, ABC):
@@ -23,6 +24,7 @@ class HaugeBot(Bot, ABC):
                          client_secret=self.CLIENT_SECRET)
         self.add_cog(VoteCog(self))
         self.add_cog(WusstestDuSchon(self))
+        self.add_cog(Wordcloud(self))
 
     @staticmethod
     async def send_me(ctx, content):
diff --git a/haugebot_twitch/vote_cog.py b/haugebot_twitch/vote_cog.py
index 8fee59301eafbcb8dc037ed29433d82a647b3c73..9a3d7ecde8a943a087ed745d02745a0e3022da1b 100644
--- a/haugebot_twitch/vote_cog.py
+++ b/haugebot_twitch/vote_cog.py
@@ -1,4 +1,3 @@
-import asyncio
 import os
 from datetime import datetime, timedelta
 
diff --git a/haugebot_twitch/wordcloud.py b/haugebot_twitch/wordcloud.py
new file mode 100644
index 0000000000000000000000000000000000000000..5bd037e7c52258c5057b3794b917b9ba6f10517a
--- /dev/null
+++ b/haugebot_twitch/wordcloud.py
@@ -0,0 +1,70 @@
+import json
+import os
+import uuid
+from datetime import datetime
+
+from twitchio.ext import commands, routines
+from websockets import connect
+
+
+def get_local_timezone():
+    return datetime.now().astimezone().tzinfo
+
+
+class Wordcloud(commands.Cog):
+    def __init__(self, bot):
+        self.bot = bot
+        self.running = False
+        self.words = {}
+        self.id = ""
+        self.start_date = None
+        self.ws_url = os.getenv("WORDCLOUD_WS_URL")
+        self.secret = os.getenv("WORDCLOUD_SECRET")
+        self.ws = None
+        self.wordcloud_routine.start(self)
+
+    @routines.routine(seconds=2)
+    async def wordcloud_routine(self):
+        try:
+            if self.running:
+                if not self.ws or self.ws.closed:
+                    self.ws = await connect(self.ws_url)
+
+                if self.ws:
+                    js = {
+                        "type": "word_update",
+                        "secret": self.secret,
+                        "uuid": str(self.id),
+                        "start_date": self.start_date,
+                        "words": self.sum_words()
+                    }
+                    print(js)
+                    await self.ws.send(json.dumps(js))
+            if not self.running:
+                if self.ws and not self.ws.closed:
+                    await self.ws.close()
+        except Exception:
+            pass
+
+    @commands.command(name="wcloud")
+    async def cmd_wcloud(self, ctx: commands.Context, phrase: str):
+        if phrase in ["start", "stop"] and ctx.author.is_mod:
+            if phrase == "start":
+                self.running = True
+                self.words = {}
+                self.id = uuid.uuid4()
+                self.start_date = datetime.now(tz=get_local_timezone()).isoformat(timespec="seconds")
+            else:
+                self.running = False
+        else:
+            self.words[ctx.author.name] = phrase
+
+    def sum_words(self):
+        words = {}
+        for user, word in self.words.items():
+            if count := words.get(word):
+                words[word] = count + 1
+            else:
+                words[word] = 1
+
+        return words
diff --git a/haugebot_web/models.py b/haugebot_web/models.py
index 35ba3890ce261b90c885389b9e04acdd9ed9d8f1..8cd484ac868a60c4c663a9b849235a7d21fb7de7 100644
--- a/haugebot_web/models.py
+++ b/haugebot_web/models.py
@@ -45,11 +45,20 @@ class TwitchUser(models.Model):
 
     @property
     def is_authenticated(self):
-        broadcaster_id = int(os.getenv("BROADCASTER_ID"))
-        if self.id == broadcaster_id or self.admin:
-            return True
+        return self.is_broadcaster or self.is_admin or self.is_mod
+
+    @property
+    def is_broadcaster(self):
+        return self.id == int(os.getenv("BROADCASTER_ID"))
+
+    @property
+    def is_mod(self):
+        return self.admin
+
+    @property
+    def is_mod(self):
         try:
-            broadcaster = TwitchUser.objects.get(pk=broadcaster_id)
+            broadcaster = TwitchUser.objects.get(pk=int(os.getenv("BROADCASTER_ID")))
             return twitch_api.is_mod(self, broadcaster)
         except TwitchUser.DoesNotExist:
             return False
diff --git a/haugebot_web/static/css/styles.css b/haugebot_web/static/css/styles.css
index 9f1bc763ca1552b1c4bfee5a869c589f3732dcff..6c7bef2a481adcf1224cf27df83431a5a03f6341 100644
--- a/haugebot_web/static/css/styles.css
+++ b/haugebot_web/static/css/styles.css
@@ -37,6 +37,10 @@
     background: var(--haugepinkdark) !important;
 }
 
+.w3-hover-haugepink-light:hover {
+    background: var(--haugepink) !important;
+}
+
 .w3-hover-haugepink.active {
     background: var(--haugepink) !important;
 }
diff --git a/haugebot_web/templates/layout.html b/haugebot_web/templates/layout.html
index 841eb0a28f55fb7d5bb5393875370cf9fcfaea80..34fcd7d85874404a800b8e91e185dc8d54d2b50e 100644
--- a/haugebot_web/templates/layout.html
+++ b/haugebot_web/templates/layout.html
@@ -19,6 +19,8 @@
             <a href="{% url 'wusstest_du_schon' %}"
                class="w3-bar-item w3-button {% if title == "Wusstest du Schon?" %}w3-light-gray{% endif %}">Wusstest du
                 Schon?</a>
+            <a href="{% url 'wordcloud' %}"
+               class="w3-bar-item w3-button {% if title == "Wordcloud" %}w3-light-gray{% endif %}">Wordcloud</a>
             <a href="{% url 'logout' %}" class="w3-bar-item w3-button">Logout</a>
         {% else %}
             <a href="{% url 'login' %}" class="w3-bar-item w3-button">Login</a>
diff --git a/haugebot_web/templates/live-wordcloud.html b/haugebot_web/templates/live-wordcloud.html
new file mode 100644
index 0000000000000000000000000000000000000000..18906fdc076cdb98c7fe3be5b927b6bc6c19d0b1
--- /dev/null
+++ b/haugebot_web/templates/live-wordcloud.html
@@ -0,0 +1,25 @@
+<!DOCTYPE html>
+<html lang="en">
+<head>
+    <meta charset="UTF-8">
+    <title>Live Wordcloud</title>
+</head>
+<body style="margin: 0; padding: 0; background: black">
+<img style="width: 100vw; height: 100vh; object-fit: contain; display: none;" src="#" id="wordcloud_img"/>
+
+</body>
+<script>
+    ws = new WebSocket("{{ ws_url }}");
+
+    ws.onmessage = function (event) {
+        let data = JSON.parse(event.data);
+        let img = document.querySelector("#wordcloud_img")
+        if (Object.keys(data.permitted).length == 0) {
+            img.style.display = "none";
+        } else {
+            img.style.display = "block";
+            img.setAttribute("src", "/media/" + data.uuid + ".png?t=" + new Date().getTime())
+        }
+    }
+</script>
+</html>
\ No newline at end of file
diff --git a/haugebot_web/templates/wordcloud.html b/haugebot_web/templates/wordcloud.html
new file mode 100644
index 0000000000000000000000000000000000000000..9307230ffb416e88d3df247b2b84bf70124914d8
--- /dev/null
+++ b/haugebot_web/templates/wordcloud.html
@@ -0,0 +1,89 @@
+{% extends 'layout.html' %}
+
+{% block content %}
+    <div class="w3-row w3-display-container w3-bottombar">
+        <a href="#">
+            <div id="tablink_filter"
+                 class="w3-col s3 l3 m3 tablink w3-hover-haugepink w3-padding active">
+                Filter
+            </div>
+        </a>
+        <!--
+        <a href="#">
+            <div id="tablink_all"
+                 class="w3-col s3 l3 m3 tablink w3-hover-haugepink w3-padding">
+                Alle Wordclouds
+            </div>
+        </a>
+        -->
+    </div>
+
+    <div class="w3-container w3-card-4 w3-white" id="filter">
+        {% if user.is_broadcaster %}
+            <h3>OBS Browser Source URL: {{ embed_link }}</h3>
+            <hr>
+        {% endif %}
+        <h3>Freigegeben</h3>
+        <ul class="w3-ul" id="permitted">
+        </ul>
+        <hr/>
+        <h3>Noch nicht freigegeben</h3>
+        <ul class="w3-ul" id="not-permitted">
+        </ul>
+    </div>
+    <div class="w3-container form" id="all"
+         style="display:none;">
+        <form method="post">
+            <input type="hidden" id="id-active" value="{{ form.name }}" name="form-active"/>
+            {% csrf_token %}
+            {% if form.display == "list" %}
+                {% if form.type == "form" %}
+                    {% include 'list_form.html' %}
+                {% elif form.type == "formset" %}
+                    {% include 'list_formset.html' %}
+                {% endif %}
+            {% elif form.display == "card" %}
+                {% if form.type == "form" %}
+                    {% include 'card_form.html' %}
+                {% elif form.type == "formset" %}
+                    {% include 'card_formset.html' %}
+                {% endif %}
+            {% endif %}
+            <input type="submit" value="Add/Save" class="w3-button w3-haugepink w3-block">
+        </form>
+    </div>
+    <script type="text/javascript">
+        ws = new WebSocket("{{ ws_url }}");
+        let permitted = document.querySelector("#permitted");
+        let notPermitted = document.querySelector("#not-permitted");
+        let session_key = "{{ session_key }}";
+
+        ws.onmessage = function (event) {
+            data = JSON.parse(event.data);
+            permitted.querySelectorAll('*').forEach(n => n.remove());
+            notPermitted.querySelectorAll('*').forEach(n => n.remove());
+            for (word in data.words) {
+                li = document.createElement("li");
+                li.classList.add("w3-hover-haugepink-light");
+                li.textContent = word;
+
+
+                if (data.permitted.includes(word)) {
+                    li.addEventListener("click", ev => {
+                        word = ev.target.textContent;
+                        message = {"type": "deny", "word": word, "session_key": session_key};
+                        ws.send(JSON.stringify(message));
+                    });
+                    permitted.append(li);
+                } else {
+                    li.addEventListener("click", ev => {
+                        word = ev.target.textContent;
+                        message = {"type": "permit", "word": word, "session_key": session_key};
+                        ws.send(JSON.stringify(message));
+                    });
+                    notPermitted.append(li);
+                }
+            }
+        }
+    </script>
+{% endblock %}
\ No newline at end of file
diff --git a/haugebot_web/views.py b/haugebot_web/views.py
index da64818a4e4c17bb02306e8b18685a065c6abe80..5d70217897945d77d1872b294fda3b312e967bb8 100644
--- a/haugebot_web/views.py
+++ b/haugebot_web/views.py
@@ -5,6 +5,7 @@ from django.contrib.auth import authenticate, login as django_login, logout as d
 from django.contrib.auth.decorators import login_required
 from django.forms import modelformset_factory
 from django.shortcuts import render, redirect
+from django.urls import reverse
 
 from .forms import BaseForm, WusstestDuSchonConfigForm
 from .models import WusstestDuSchon
@@ -58,6 +59,19 @@ def wusstest_du_schon_remove(request, id):
     return redirect("/wusstest_du_schon")
 
 
+@login_required(login_url="/login")
+def wordcloud(request):
+    id = os.getenv("DJANGO_WORDCLOUD_LIVE_ID")
+    embed_link = f"{request.scheme}://{request.headers['Host']}{reverse('wordcloud_live', args=(id,))}" if request.user.is_broadcaster else ""
+    return render(request, "wordcloud.html", {'title': 'Wordcloud', "ws_url": os.getenv("WORDCLOUD_WS_URL"),
+                                              "session_key": request.session.session_key, "embed_link": embed_link})
+
+
+def wordcloud_live(request, id):
+    if id == os.getenv("DJANGO_WORDCLOUD_LIVE_ID"):
+        return render(request, "live-wordcloud.html", {"ws_url": os.getenv("WORDCLOUD_WS_URL")})
+
+
 def login(request):
     client_id = os.getenv("CLIENT_ID")
     redirect_uri = os.getenv("REDIRECT_URI")
diff --git a/requirements.txt b/requirements.txt
index f63958d3e1a0cbd9c11925db7204ab501a864f09..6d04985412b95f16c575d6813b77ebf45021f74e 100644
--- a/requirements.txt
+++ b/requirements.txt
@@ -1,10 +1,10 @@
 aiohttp==3.7.4
-asgiref==3.3.1
+asgiref==3.4.1
 async-timeout==3.0.1
 attrs==20.3.0
 certifi==2020.12.5
 chardet==3.0.4
-Django==3.1.12
+Django==3.2.5
 idna==2.10
 multidict==5.1.0
 python-dotenv==0.15.0
@@ -14,6 +14,6 @@ requests==2.25.1
 sqlparse==0.4.1
 twitchio==2.0.0b7
 typing-extensions==3.7.4.3
-websockets==9.1
 urllib3==1.26.5
+websockets==9.1
 yarl==1.6.3