diff --git a/constraints.txt b/constraints.txt
index fa19acdaa0ad12cbc89e11be82e184fd8aa4d86c..9c4dbd343b0917fdd6123a6f3370488ea8a965fc 100644
--- a/constraints.txt
+++ b/constraints.txt
@@ -1,4 +1,5 @@
 bcrypt==3.2.0
+bleach==3.3.0
 blinker==1.4
 cffi==1.14.4
 click==7.1.2
@@ -11,6 +12,7 @@ Flask-WTF==0.14.3
 itsdangerous==1.1.0
 Jinja2==2.11.2
 lxml==4.6.2
+markdown==3.3.4
 MarkupSafe==1.1.1
 pikepdf==2.3.0
 Pillow==8.1.0
diff --git a/db/db.ddl b/db/db.ddl
index 73a341395035c1a40837c13faaa97cff85dff2ca..7e27eb955a61e9ab922a18fdf60801f9699f9e5b 100644
--- a/db/db.ddl
+++ b/db/db.ddl
@@ -107,6 +107,7 @@ CREATE TABLE rounds (
 	score_mode	score_mode	NOT NULL DEFAULT 'basic',	-- mód výsledkovky
 	score_winner_limit	int	DEFAULT NULL,			-- bodový limit na označení za vítěze
 	score_successful_limit	int	DEFAULT NULL,			-- bodový limit na označení za úspěšného řešitele
+	has_messages	boolean		NOT NULL DEFAULT false,		-- má zprávičky
 	UNIQUE (year, category, seq, part)
 );
 
@@ -298,3 +299,15 @@ CREATE TABLE jobs (
 	in_file		varchar(255)	DEFAULT NULL,
 	out_file	varchar(255)	DEFAULT NULL
 );
+
+-- Zprávičky k soutěžím
+
+CREATE TABLE messages (
+	message_id	serial		PRIMARY KEY,
+	round_id	int		NOT NULL REFERENCES rounds(round_id),
+	created_at	timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,	-- čas publikování zprávičky
+	created_by	int		NOT NULL REFERENCES users(user_id),		-- autor zprávičky
+	title		text		NOT NULL,
+	markdown	text		NOT NULL,
+	html		text		NOT NULL
+);
diff --git a/db/upgrade-20210322.sql b/db/upgrade-20210322.sql
new file mode 100644
index 0000000000000000000000000000000000000000..305d2e742cb1e149dc781f077f742fd7829c1e85
--- /dev/null
+++ b/db/upgrade-20210322.sql
@@ -0,0 +1,16 @@
+SET ROLE 'mo_osmo';
+
+-- Zprávičky k soutěžím
+
+ALTER TABLE rounds
+	ADD COLUMN has_messages	boolean		NOT NULL DEFAULT false;			-- má zprávičky
+
+CREATE TABLE messages (
+	message_id	serial		PRIMARY KEY,
+	round_id	int		NOT NULL REFERENCES rounds(round_id),
+	created_at	timestamp with time zone NOT NULL DEFAULT CURRENT_TIMESTAMP,	-- čas publikování zprávičky
+	created_by	int		NOT NULL REFERENCES users(user_id),		-- autor zprávičky
+	title		text		NOT NULL,
+	markdown	text		NOT NULL,
+	html		text		NOT NULL
+);
diff --git a/mo/db.py b/mo/db.py
index 4f8fa8ee55caf5129dffffbc8dc78b3894229eb6..5d2c50189a63c27e52d5a01d9a96d216c8eb8e14 100644
--- a/mo/db.py
+++ b/mo/db.py
@@ -209,6 +209,7 @@ class Round(Base):
     score_mode = Column(Enum(RoundScoreMode, name='score_mode'), nullable=False, server_default=text("'basic'::score_mode"))
     score_winner_limit = Column(Integer)
     score_successful_limit = Column(Integer)
+    has_messages = Column(Boolean, nullable=False, server_default=text("false"))
 
     master = relationship('Round', primaryjoin='Round.master_round_id == Round.round_id', remote_side='Round.round_id', post_update=True)
 
@@ -592,6 +593,20 @@ class Job(Base):
     user = relationship('User')
 
 
+class Message(Base):
+    __tablename__ = 'messages'
+
+    message_id = Column(Integer, primary_key=True, server_default=text("nextval('messages_message_id_seq'::regclass)"))
+    round_id = Column(Integer, ForeignKey('rounds.round_id'), nullable=False)
+    created_at = Column(DateTime(True), nullable=False, server_default=text("CURRENT_TIMESTAMP"))
+    created_by = Column(Integer, ForeignKey('users.user_id'))
+    title = Column(Text, nullable=False)
+    markdown = Column(Text, nullable=False)
+    html = Column(Text, nullable=False)
+
+    created_by_user = relationship('User')
+
+
 _engine: Optional[Engine] = None
 _session: Optional[Session] = None
 flask_db: Any = None
diff --git a/mo/web/__init__.py b/mo/web/__init__.py
index 57d40439ae83733a020ea5f2f68b0c024c609c71..61f8fa51511f74d58db02cf711501a11b4640cdb 100644
--- a/mo/web/__init__.py
+++ b/mo/web/__init__.py
@@ -114,6 +114,7 @@ mo.ext.assets.Assets(app, url_prefix='/assets', asset_dir=static_dir)
 app.assets.add_assets([
     'bootstrap.min.css',
     'mo.css',
+    'js/news-reloader.js',
 ])
 
 
diff --git a/mo/web/org_round.py b/mo/web/org_round.py
index 979093b19910c10f96cda26db941ac362ffd935c..4ac98010b07a3e1d8e43c550a4c416fd05c3f410 100644
--- a/mo/web/org_round.py
+++ b/mo/web/org_round.py
@@ -1,6 +1,9 @@
 from flask import render_template, g, redirect, url_for, flash, request
 import locale
 from flask_wtf.form import FlaskForm
+import bleach
+from bleach.sanitizer import ALLOWED_TAGS
+import markdown
 from sqlalchemy import func
 from sqlalchemy.orm import joinedload
 from sqlalchemy.sql.functions import coalesce
@@ -428,6 +431,7 @@ class RoundEditForm(FlaskForm):
         "Hranice bodů pro úspěšné řešitele", validators=[validators.Optional()],
         description="Řešitelé s alespoň tolika body budou označeni za úspěšné řešitele, prázdná hodnota = žádné neoznačovat",
     )
+    has_messages = wtforms.BooleanField("Zprávičky pro účastníky (aktivuje možnost vytvářet novinky zobrazované účastníkům)")
     submit = wtforms.SubmitField('Uložit')
 
     def validate_state(self, field):
@@ -489,3 +493,78 @@ def org_task_statement(id: int):
         raise werkzeug.exceptions.Forbidden()
 
     return mo.web.util.send_task_statement(round)
+
+
+class MessageAddForm(FlaskForm):
+    title = wtforms.StringField('Nadpis', validators=[validators.Required()])
+    markdown = wtforms.TextAreaField(
+        'Text novinky', description='Zprávičky lze formátovat pomocí Markdownu',
+        validators=[validators.Required()],
+        render_kw={'rows': 10},
+    )
+    submit = wtforms.SubmitField(label='Vložit zprávičku')
+    preview = wtforms.SubmitField(label='Zobrazit náhled')
+
+
+class MessageRemoveForm(FlaskForm):
+    message_id = wtforms.IntegerField(validators=[validators.Required()])
+    message_remove = wtforms.SubmitField()
+
+
+@app.route('/org/contest/r/<int:id>/messages/', methods=('GET', 'POST'))
+def org_round_messages(id: int):
+    sess = db.get_session()
+    round, _, rr = get_round_rr(id, None, True)
+
+    if not round.has_messages:
+        flash('Toto kolo nemá aktivní zprávičky pro účastníky, aktivujte je v nastavení kola', 'warning')
+        return redirect(url_for('org_round', id=id))
+
+    messages = sess.query(db.Message).filter_by(round_id=id).order_by(db.Message.created_at).all()
+
+    add_form: Optional[MessageAddForm] = None
+    remove_form: Optional[MessageRemoveForm] = None
+    preview: Optional[db.Message] = None
+    if rr.have_right(Right.manage_round):
+        add_form = MessageAddForm()
+        remove_form = MessageRemoveForm()
+
+        if remove_form.validate_on_submit() and remove_form.message_remove.data:
+            msg = sess.query(db.Message).get(remove_form.message_id.data)
+            if not msg or msg.round_id != id:
+                raise werkzeug.exceptions.NotFound()
+            sess.delete(msg)
+            sess.commit()
+            app.logger.info(f"Zprávička pro kolo {id} odstraněna: {db.row2dict(msg)}")
+
+            flash('Zprávička odstraněna', 'success')
+            return redirect(url_for('org_round_messages', id=id))
+
+        if add_form.validate_on_submit():
+            msg = db.Message(
+                round_id=id,
+                created_by=g.user.user_id,
+                created_at=mo.now,
+            )
+            add_form.populate_obj(msg)
+            msg.html = bleach.clean(
+                markdown.markdown(msg.markdown),
+                tags=ALLOWED_TAGS+['p']
+            )
+
+            if add_form.preview.data:
+                preview = msg
+            elif add_form.submit.data:
+                sess.add(msg)
+                sess.commit()
+                app.logger.info(f"Vložena nová zprávička pro kolo {id}: {db.row2dict(msg)}")
+
+                flash('Zprávička úspěšně vložena', 'success')
+                return redirect(url_for('org_round_messages', id=id))
+
+    return render_template(
+        'org_round_messages.html',
+        round=round, rr=rr, messages=messages,
+        add_form=add_form, remove_form=remove_form,
+        preview=preview,
+    )
diff --git a/mo/web/templates/org_round.html b/mo/web/templates/org_round.html
index 6f4a2a560f656e64f0036bb4ca0582659fe636ab..6c5705dc35258985d0b6b711ac38ae08ee7e4e2e 100644
--- a/mo/web/templates/org_round.html
+++ b/mo/web/templates/org_round.html
@@ -58,6 +58,9 @@
 	{% if can_manage_round %}
 	<a class="btn btn-default" href='{{ url_for('org_round_edit', id=round.round_id) }}'>Editovat nastavení kola</a>
 	{% endif %}
+	{% if round.has_messages %}
+	<a class="btn btn-default" href='{{ url_for('org_round_messages', id=round.round_id) }}'>Zprávičky</a>
+	{% endif %}
 	{% if g.user.is_admin %}
 	<a class="btn btn-default" href='{{ log_url('round', round.round_id) }}'>Historie</a>
 	{% endif %}
diff --git a/mo/web/templates/org_round_messages.html b/mo/web/templates/org_round_messages.html
new file mode 100644
index 0000000000000000000000000000000000000000..1be2a7367b9db1ef420ee6ad65f7a19d925270f4
--- /dev/null
+++ b/mo/web/templates/org_round_messages.html
@@ -0,0 +1,34 @@
+{% extends "base.html" %}
+{% import "bootstrap/wtf.html" as wtf %}
+
+{% block title %}{{ round.name }} {{ round.round_code() }} – zprávičky{% endblock %}
+{% block breadcrumbs %}
+{{ contest_breadcrumbs(round=round, action='Zprávičky') }}
+{% endblock %}
+
+{% block body %}
+
+{% if messages %}
+{% with message_remove_form=remove_form %}
+	{% include "parts/messages.html" %}
+{% endwith %}
+{% else %}<p><i>Žádné zprávičky k vypsání.</i></p>{% endif %}
+
+{% if add_form %}
+	<h3>Nová zprávička</h3>
+	<div class="form-frame">
+	{{ wtf.quick_form(add_form, button_map={'submit': 'primary', 'preview': 'default'}) }}
+	</div>
+	{% if preview %}
+	<div class="form-frame">
+		<b>Náhled zprávičky:</b>
+		{% with messages = [preview] %}
+		{% include "parts/messages.html" %}
+		{% endwith %}
+	</div>
+	{% endif %}
+{% else %}
+	<p><i>Nemáte právo k přidávání nových zpráviček.</i></p>
+{% endif %}
+
+{% endblock %}
diff --git a/mo/web/templates/parts/messages.html b/mo/web/templates/parts/messages.html
new file mode 100644
index 0000000000000000000000000000000000000000..d90436f4af4b3bcb363f896936d3ba7ab1249099
--- /dev/null
+++ b/mo/web/templates/parts/messages.html
@@ -0,0 +1,14 @@
+{% for msg in messages %}
+<div class="message">
+	<span class="msg-title">{{ msg.title }}</span>
+	<span class="msg-date">{{ msg.created_at|time_and_timedelta }}</span>
+	<div class="msg-text">{{ msg.html|safe }}</div>
+	{% if message_remove_form %}
+		<form method="POST" onsubmit="return confirm('Opravdu nenávratně smazat?');" style="float: right; margin-top: -20px;">
+			{{ message_remove_form.csrf_token }}
+			<input type="hidden" name="message_id" value="{{ msg.message_id }}">
+			<input type="submit" name="message_remove" class="btn btn-xs btn-danger" value="Smazat">
+		</form>
+	{% endif %}
+</div>
+{% endfor %}
diff --git a/mo/web/templates/parts/user_news.html b/mo/web/templates/parts/user_news.html
new file mode 100644
index 0000000000000000000000000000000000000000..b873d2058644274e61161ae5d7f1f661efd86019
--- /dev/null
+++ b/mo/web/templates/parts/user_news.html
@@ -0,0 +1,6 @@
+<div id="novinky">
+{% include "parts/messages.html" %}
+</div>
+<script type="text/javascript">
+r = new NewsReloader(document.getElementById("novinky"), "{{ url }}", 60000);
+</script>
diff --git a/mo/web/templates/user_contest.html b/mo/web/templates/user_contest.html
index d784945c5db5d002c8ff05b20ec330bf12e360bd..c676c07394e491d7d3be1be614f5f24cdf039fee 100644
--- a/mo/web/templates/user_contest.html
+++ b/mo/web/templates/user_contest.html
@@ -3,6 +3,12 @@
 {% set round = contest.round %}
 {% set state = contest.ct_state() %}
 
+{% block head %}
+{% if contest.round.has_messages %}
+	<script src="{{ asset_url('js/news-reloader.js') }}" type="text/javascript"></script>
+{% endif %}
+{% endblock %}
+
 {% block title %}{{ round.name }} {{ round.year }}. ročníku kategorie {{ round.category }}: {{ contest.place.name }}{% endblock %}
 {% block breadcrumbs %}
 <li><a href='{{ url_for('user_index') }}'>Soutěže</a>
@@ -98,4 +104,12 @@
 </table>
 
 {% endif %}
+
+{% if contest.round.has_messages %}
+<h3>Novinky k soutěži</h3>
+{% with title="Novinky k soutěži", url=url_for('user_contest_news', id=contest.contest_id) %}
+	{% include "parts/user_news.html" %}
+{% endwith %}
+{% endif %}
+
 {% endblock %}
diff --git a/mo/web/templates/user_contest_task.html b/mo/web/templates/user_contest_task.html
index 525f1071e9614bfbb259c8c826eaa19cb478ac2a..01c3263b12f025e2e890a5b7520b3645d587a96d 100644
--- a/mo/web/templates/user_contest_task.html
+++ b/mo/web/templates/user_contest_task.html
@@ -3,6 +3,12 @@
 {% set round = contest.round %}
 {% set state = contest.ct_state() %}
 
+{% block head %}
+{% if contest.round.has_messages %}
+	<script src="{{ asset_url('js/news-reloader.js') }}" type="text/javascript"></script>
+{% endif %}
+{% endblock %}
+
 {% block title %}Úloha {{ task.code }}: {{ task.name }}{% endblock %}
 {% block breadcrumbs %}
 <li><a href='{{ url_for('user_index') }}'>Soutěže</a>
@@ -85,4 +91,11 @@
 {% endif %}
 {% endif %}
 
+{% if contest.round.has_messages %}
+<h3>Novinky k soutěži</h3>
+{% with title="Novinky k soutěži", url=url_for('user_contest_news', id=contest.contest_id) %}
+	{% include "parts/user_news.html" %}
+{% endwith %}
+{% endif %}
+
 {% endblock %}
diff --git a/mo/web/user.py b/mo/web/user.py
index a4cf524e43b7ff02b808b82a8ca097feaa683793..e54667f5e6b7cfc28ac7022658cafa175ef0e269 100644
--- a/mo/web/user.py
+++ b/mo/web/user.py
@@ -1,17 +1,17 @@
-from flask import render_template, request, g, redirect, url_for, flash
+from flask import render_template, jsonify, g, redirect, url_for, flash
 from flask_wtf import FlaskForm
 import flask_wtf.file
 from sqlalchemy import and_
 from sqlalchemy.orm import joinedload
 import werkzeug.exceptions
 import wtforms
-import wtforms.validators as validators
 
 import mo.config as config
 import mo.db as db
 import mo.submit
 import mo.util
 from mo.util import logger
+from mo.util_format import time_and_timedelta
 from mo.web import app
 import mo.web.util
 
@@ -66,9 +66,12 @@ def get_task(contest: db.Contest, id: int) -> db.Task:
 
 @app.route('/user/contest/<int:id>/')
 def user_contest(id: int):
+    sess = db.get_session()
     contest = get_contest(id)
 
-    task_sols = (db.get_session().query(db.Task, db.Solution)
+    messages = sess.query(db.Message).filter_by(round_id=contest.round_id).order_by(db.Message.created_at).all()
+
+    task_sols = (sess.query(db.Task, db.Solution)
                  .select_from(db.Task)
                  .outerjoin(db.Solution, and_(db.Solution.task_id == db.Task.task_id, db.Solution.user == g.user))
                  .filter(db.Task.round == contest.round)
@@ -81,10 +84,27 @@ def user_contest(id: int):
         'user_contest.html',
         contest=contest,
         task_sols=task_sols,
+        messages=messages,
         max_submit_size=config.MAX_CONTENT_LENGTH,
     )
 
 
+@app.route('/user/contest/<int:id>/news')
+def user_contest_news(id: int):
+    sess = db.get_session()
+    contest = get_contest(id)
+
+    messages = sess.query(db.Message).filter_by(round_id=contest.round_id).order_by(db.Message.created_at).all()
+
+    out_messages = [{
+        'title': msg.title,
+        'date_format': time_and_timedelta(msg.created_at),
+        'body': msg.html,
+    } for msg in messages]
+
+    return jsonify(out_messages)
+
+
 @app.route('/user/contest/<int:id>/task-statement/zadani.pdf')
 def user_task_statement(id: int):
     contest = get_contest(id)
@@ -108,6 +128,8 @@ def user_contest_task(contest_id: int, task_id: int):
     task = get_task(contest, task_id)
     sess = db.get_session()
 
+    messages = sess.query(db.Message).filter_by(round_id=contest.round_id).order_by(db.Message.created_at).all()
+
     state = contest.ct_state()
     if state == db.RoundState.preparing:
         # Dokud se kolo připravuje nebo čeká na zveřejnění zadání, tak ani nezobrazujeme
@@ -164,6 +186,7 @@ def user_contest_task(contest_id: int, task_id: int):
         sol=sol,
         papers=papers,
         form=form,
+        messages=messages,
     )
 
 
diff --git a/setup.py b/setup.py
index 5b7705e6c8a4f95cd17c41da0ca88e673970ecb9..2d45478b040f681c819e30e037937a6ffc4cc267 100644
--- a/setup.py
+++ b/setup.py
@@ -23,11 +23,13 @@ setuptools.setup(
         'Flask-WTF',
         'WTForms',
         'bcrypt',
+        'bleach',
         'blinker',
         'click',
         'dateutils',
         'flask_bootstrap',
         'flask_sqlalchemy',
+        'markdown',
         'pikepdf',
         'psycopg2',
         'sqlalchemy',
diff --git a/static/js/news-reloader.js b/static/js/news-reloader.js
new file mode 100644
index 0000000000000000000000000000000000000000..04fe721d2224264a2c76932ecdf0d413ee9bb73b
--- /dev/null
+++ b/static/js/news-reloader.js
@@ -0,0 +1,81 @@
+class NewsReloader {
+	news_count = 0;
+	notification_interval = null;
+	original_title = "";
+
+	constructor(element, url, check_interval=60000) {
+		this.element = element;
+		this.url = url;
+		this.check_interval = check_interval;
+
+		this.news_count = element.childElementCount;
+		this.original_title = document.title;
+
+		var t = this
+		setInterval(function() { t.refreshNews();}, this.check_interval);
+		window.addEventListener('focus', function() {
+			t.news_count = t.element.childElementCount;
+			t.notificationOff();
+		});
+	}
+
+	notificationOn(notification) {
+		clearInterval(this.notification_interval); // clear any previous interval
+		var t = this;
+		this.notification_interval = setInterval(function() {
+			document.title = (t.original_title == document.title)
+					? notification + t.original_title
+					: t.original_title;
+		}, 1000);
+	}
+
+	notificationOff() {
+		if (this.notification_interval) {
+			clearInterval(this.notification_interval);
+			document.title = this.original_title;
+		}
+	}
+
+	refreshNews() {
+		var xmlhttp = new XMLHttpRequest();
+		var t = this;
+		xmlhttp.onreadystatechange = function() {
+			if (this.readyState == 4 && this.status == 200) {
+				var newsArr = JSON.parse(this.responseText);
+				var count = newsArr.length
+
+				var markN = 0; // how many elements to mark with class "new"
+				if (count > t.news_count) {
+					markN = count - t.news_count;
+				}
+
+				// Create all new <div>s
+				var newElements = document.createDocumentFragment();
+				for (var i = 0; i < count; i++) {
+					var div = document.createElement("div");
+					div.className = "message";
+					if (i + markN >= count) div.className += " new"; // mark N last elements
+					div.innerHTML = "<span class='msg-title'>" + newsArr[i]["title"] + "</span>"
+							+"<span class='msg-date'>" + newsArr[i]["date_format"] + "</span>"
+							+"<div class='msg-text'>" + newsArr[i]["body"] + "</div>";
+					newElements.appendChild(div);
+				}
+
+				// Remove all childs and append new
+				t.element.innerHTML = "";
+				t.element.appendChild(newElements);
+
+				// Notification
+				if (count != t.news_count) {
+					if (document.hasFocus()) t.news_count = count;
+					else {
+						// Add (1) to the title (with removing any previous value)
+						t.notificationOn("(" + Math.abs(count - t.news_count).toString() + ") ");
+					}
+				}
+			}
+		}
+		xmlhttp.open("GET", this.url, true);
+		xmlhttp.send();
+	}
+}
diff --git a/static/mo.css b/static/mo.css
index 9826de39d25461d82bfbf4df2e5c60e7bfc90c22..dec13006ac04a3a4b4393624fa95db3c9aeca644 100644
--- a/static/mo.css
+++ b/static/mo.css
@@ -382,3 +382,24 @@ div.alert + div.alert {
 .collapsible input[type="checkbox"].toggle:checked ~ .collapsible-inner {
 	max-height: 100vh;
 }
+
+div.message {
+	padding: 5px 10px;
+	margin-bottom: 5px;
+	border-radius: 5px;
+	border: solid 1px #f8d99b;
+	color:#46381f;
+	background-color:#fff8d5;
+}
+div.message.new {
+	background-color: #ffdede;
+	border: solid 1px #ff5e5e;
+}
+div.message .msg-title {
+	font-weight: bold;
+}
+div.message .msg-date {
+	float: right;
+	font-style: italic;
+	color: #777;
+}