diff --git a/code/__DEFINES/layers_planes.dm b/code/__DEFINES/layers_planes.dm
index c7be89721e..cde08c3a60 100644
--- a/code/__DEFINES/layers_planes.dm
+++ b/code/__DEFINES/layers_planes.dm
@@ -153,6 +153,9 @@
#define CAMERA_STATIC_LAYER 19
#define CAMERA_STATIC_RENDER_TARGET "CAMERA_STATIC_PLANE"
+/// Over lighting and every other crap, this is nearly as important as hud content and only visible to the user.
+#define BALLOON_CHAT_PLANE 20
+
//HUD layer defines
#define FULLSCREEN_PLANE 20
diff --git a/code/__DEFINES/text.dm b/code/__DEFINES/text.dm
new file mode 100644
index 0000000000..58c24747e0
--- /dev/null
+++ b/code/__DEFINES/text.dm
@@ -0,0 +1,5 @@
+/// Prepares a text to be used for maptext. Use this so it doesn't look hideous.
+#define MAPTEXT(text) {"[##text]"}
+
+/// Macro from Lummox used to get height from a MeasureText proc
+#define WXH_TO_HEIGHT(x) text2num(copytext(x, findtextEx(x, "x") + 1))
diff --git a/code/__HELPERS/game.dm b/code/__HELPERS/game.dm
index e55faab1be..23dd63b4fd 100644
--- a/code/__HELPERS/game.dm
+++ b/code/__HELPERS/game.dm
@@ -402,6 +402,10 @@
O.screen_loc = screen_loc
return O
+/// Removes an image from a client's `.images`. Useful as a callback.
+/proc/remove_image_from_client(image/image, client/remove_from)
+ remove_from?.images -= image
+
/proc/remove_images_from_clients(image/I, list/show_to)
for(var/client/C in show_to)
C.images -= I
diff --git a/code/datums/chatmessage.dm b/code/datums/chatmessage.dm
index eb49323464..fb95565d90 100644
--- a/code/datums/chatmessage.dm
+++ b/code/datums/chatmessage.dm
@@ -18,8 +18,6 @@
#define CHAT_LAYER_Z_STEP 0.0001
/// The number of z-layer 'slices' usable by the chat message layering
#define CHAT_LAYER_MAX_Z (CHAT_LAYER_MAX - CHAT_LAYER) / CHAT_LAYER_Z_STEP
-/// Macro from Lummox used to get height from a MeasureText proc
-#define WXH_TO_HEIGHT(x) text2num(copytext(x, findtextEx(x, "x") + 1))
/**
* # Chat Message Overlay
diff --git a/code/modules/balloon_alert/balloon_alert.dm b/code/modules/balloon_alert/balloon_alert.dm
new file mode 100644
index 0000000000..8a25206ed0
--- /dev/null
+++ b/code/modules/balloon_alert/balloon_alert.dm
@@ -0,0 +1,90 @@
+#define BALLOON_TEXT_WIDTH 200
+#define BALLOON_TEXT_SPAWN_TIME (0.2 SECONDS)
+#define BALLOON_TEXT_FADE_TIME (0.1 SECONDS)
+#define BALLOON_TEXT_FULLY_VISIBLE_TIME (0.7 SECONDS)
+#define BALLOON_TEXT_TOTAL_LIFETIME(mult) (BALLOON_TEXT_SPAWN_TIME + BALLOON_TEXT_FULLY_VISIBLE_TIME*mult + BALLOON_TEXT_FADE_TIME)
+/// The increase in duration per character in seconds
+#define BALLOON_TEXT_CHAR_LIFETIME_INCREASE_MULT (0.05)
+/// The amount of characters needed before this increase takes into effect
+#define BALLOON_TEXT_CHAR_LIFETIME_INCREASE_MIN 10
+
+/// Creates text that will float from the atom upwards to the viewer.
+/atom/proc/balloon_alert(mob/viewer, text)
+ SHOULD_NOT_SLEEP(TRUE)
+
+ INVOKE_ASYNC(src, .proc/balloon_alert_perform, viewer, text)
+
+/// Create balloon alerts (text that floats up) to everything within range.
+/// Will only display to people who can see.
+/atom/proc/balloon_alert_to_viewers(message, self_message, vision_distance = DEFAULT_MESSAGE_RANGE, list/ignored_mobs)
+ SHOULD_NOT_SLEEP(TRUE)
+
+ var/list/hearers = get_hearers_in_view(vision_distance, src)
+ hearers -= ignored_mobs
+
+ for (var/mob/hearer in hearers)
+ if (hearer.is_blind())
+ continue
+
+ balloon_alert(hearer, (hearer == src && self_message) || message)
+
+// Do not use.
+// MeasureText blocks. I have no idea for how long.
+// I would've made the maptext_height update on its own, but I don't know
+// if this would look bad on laggy clients.
+/atom/proc/balloon_alert_perform(mob/viewer, text)
+ var/client/viewer_client = viewer.client
+ if (isnull(viewer_client))
+ return
+
+ var/bound_width = world.icon_size
+ if (ismovable(src))
+ var/atom/movable/movable_source = src
+ bound_width = movable_source.bound_width
+
+ var/image/balloon_alert = image(loc = get_atom_on_turf(src), layer = ABOVE_MOB_LAYER)
+ balloon_alert.plane = BALLOON_CHAT_PLANE
+ balloon_alert.alpha = 0
+ balloon_alert.appearance_flags = RESET_ALPHA|RESET_COLOR|RESET_TRANSFORM
+ balloon_alert.maptext = MAPTEXT("[text]")
+ balloon_alert.maptext_x = (BALLOON_TEXT_WIDTH - bound_width) * -0.5
+ balloon_alert.maptext_height = WXH_TO_HEIGHT(viewer_client?.MeasureText(text, null, BALLOON_TEXT_WIDTH))
+ balloon_alert.maptext_width = BALLOON_TEXT_WIDTH
+
+ viewer_client?.images += balloon_alert
+
+ var/duration_mult = 1
+ var/duration_length = length(text) - BALLOON_TEXT_CHAR_LIFETIME_INCREASE_MIN
+
+ if(duration_length > 0)
+ duration_mult += duration_length*BALLOON_TEXT_CHAR_LIFETIME_INCREASE_MULT
+
+ animate(
+ balloon_alert,
+ pixel_y = world.icon_size * 1.2,
+ time = BALLOON_TEXT_TOTAL_LIFETIME(1),
+ easing = SINE_EASING | EASE_OUT,
+ )
+
+ animate(
+ alpha = 255,
+ time = BALLOON_TEXT_SPAWN_TIME,
+ easing = CUBIC_EASING | EASE_OUT,
+ flags = ANIMATION_PARALLEL,
+ )
+
+ animate(
+ alpha = 0,
+ time = BALLOON_TEXT_FULLY_VISIBLE_TIME*duration_mult,
+ easing = CUBIC_EASING | EASE_IN,
+ )
+
+ addtimer(CALLBACK(GLOBAL_PROC, .proc/remove_image_from_client, balloon_alert, viewer_client), BALLOON_TEXT_TOTAL_LIFETIME(duration_mult))
+
+#undef BALLOON_TEXT_CHAR_LIFETIME_INCREASE_MIN
+#undef BALLOON_TEXT_CHAR_LIFETIME_INCREASE_MULT
+#undef BALLOON_TEXT_FADE_TIME
+#undef BALLOON_TEXT_FULLY_VISIBLE_TIME
+#undef BALLOON_TEXT_SPAWN_TIME
+#undef BALLOON_TEXT_TOTAL_LIFETIME
+#undef BALLOON_TEXT_WIDTH
diff --git a/code/modules/reagents/reagent_containers.dm b/code/modules/reagents/reagent_containers.dm
index 8744af7b28..aa871723a3 100644
--- a/code/modules/reagents/reagent_containers.dm
+++ b/code/modules/reagents/reagent_containers.dm
@@ -66,7 +66,7 @@
amount_per_transfer_from_this = possible_transfer_amounts[i+1]
else
amount_per_transfer_from_this = possible_transfer_amounts[1]
- to_chat(user, "[src]'s transfer amount is now [amount_per_transfer_from_this] units.")
+ balloon_alert(user, "Transferring [amount_per_transfer_from_this]u")
return
/obj/item/reagent_containers/attack(mob/living/M, mob/living/user, attackchain_flags = NONE, damage_multiplier = 1)
diff --git a/tgstation.dme b/tgstation.dme
index 79aef22354..0d0171ce0e 100644
--- a/tgstation.dme
+++ b/tgstation.dme
@@ -119,6 +119,7 @@
#include "code\__DEFINES\status_effects.dm"
#include "code\__DEFINES\strippable.dm"
#include "code\__DEFINES\subsystems.dm"
+#include "code\__DEFINES\text.dm"
#include "code\__DEFINES\tgs.config.dm"
#include "code\__DEFINES\tgs.dm"
#include "code\__DEFINES\tgui.dm"
@@ -1905,6 +1906,7 @@
#include "code\modules\awaymissions\mission_code\stationCollision.dm"
#include "code\modules\awaymissions\mission_code\undergroundoutpost45.dm"
#include "code\modules\awaymissions\mission_code\wildwest.dm"
+#include "code\modules\balloon_alert\balloon_alert.dm"
#include "code\modules\buildmode\bm_mode.dm"
#include "code\modules\buildmode\buildmode.dm"
#include "code\modules\buildmode\buttons.dm"