diff --git a/Content.Server/Ghost/Components/SpookySpeakerComponent.cs b/Content.Server/Ghost/Components/SpookySpeakerComponent.cs
new file mode 100644
index 0000000000..e843793a13
--- /dev/null
+++ b/Content.Server/Ghost/Components/SpookySpeakerComponent.cs
@@ -0,0 +1,37 @@
+using Content.Shared.Dataset;
+using Robust.Shared.Prototypes;
+
+namespace Content.Server.Ghost.Components;
+
+///
+/// Causes this entity to react to ghost player using the "Boo!" action by speaking
+/// a randomly chosen message from a specified set.
+///
+[RegisterComponent, AutoGenerateComponentPause]
+public sealed partial class SpookySpeakerComponent : Component
+{
+ ///
+ /// ProtoId of the LocalizedDataset to use for messages.
+ ///
+ [DataField(required: true)]
+ public ProtoId MessageSet;
+
+ ///
+ /// Probability that this entity will speak if activated by a Boo action.
+ /// This is so whole banks of entities don't trigger at the same time.
+ ///
+ [DataField]
+ public float SpeakChance = 0.5f;
+
+ ///
+ /// Minimum time that must pass after speaking before this entity can speak again.
+ ///
+ [DataField]
+ public TimeSpan Cooldown = TimeSpan.FromSeconds(30);
+
+ ///
+ /// Time when the cooldown will have elapsed and the entity can speak again.
+ ///
+ [DataField, AutoPausedField]
+ public TimeSpan NextSpeakTime;
+}
diff --git a/Content.Server/Ghost/GhostSystem.cs b/Content.Server/Ghost/GhostSystem.cs
index 71a75ee4c6..25319557ae 100644
--- a/Content.Server/Ghost/GhostSystem.cs
+++ b/Content.Server/Ghost/GhostSystem.cs
@@ -34,6 +34,7 @@ using Robust.Shared.Physics.Components;
using Robust.Shared.Physics.Systems;
using Robust.Shared.Player;
using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
using Robust.Shared.Timing;
namespace Content.Server.Ghost
@@ -63,6 +64,7 @@ namespace Content.Server.Ghost
[Dependency] private readonly GameTicker _gameTicker = default!;
[Dependency] private readonly DamageableSystem _damageable = default!;
[Dependency] private readonly SharedPopupSystem _popup = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
private EntityQuery _ghostQuery;
private EntityQuery _physicsQuery;
@@ -127,7 +129,9 @@ namespace Content.Server.Ghost
if (args.Handled)
return;
- var entities = _lookup.GetEntitiesInRange(args.Performer, component.BooRadius);
+ var entities = _lookup.GetEntitiesInRange(args.Performer, component.BooRadius).ToList();
+ // Shuffle the possible targets so we don't favor any particular entities
+ _random.Shuffle(entities);
var booCounter = 0;
foreach (var ent in entities)
diff --git a/Content.Server/Ghost/SpookySpeakerSystem.cs b/Content.Server/Ghost/SpookySpeakerSystem.cs
new file mode 100644
index 0000000000..5a83ca39b1
--- /dev/null
+++ b/Content.Server/Ghost/SpookySpeakerSystem.cs
@@ -0,0 +1,51 @@
+using Content.Server.Chat.Systems;
+using Content.Server.Ghost.Components;
+using Content.Shared.Random.Helpers;
+using Robust.Shared.Prototypes;
+using Robust.Shared.Random;
+using Robust.Shared.Timing;
+
+namespace Content.Server.Ghost;
+
+public sealed class SpookySpeakerSystem : EntitySystem
+{
+ [Dependency] private readonly IPrototypeManager _proto = default!;
+ [Dependency] private readonly IRobustRandom _random = default!;
+ [Dependency] private readonly IGameTiming _timing = default!;
+ [Dependency] private readonly ChatSystem _chat = default!;
+
+ public override void Initialize()
+ {
+ base.Initialize();
+
+ SubscribeLocalEvent(OnGhostBoo);
+ }
+
+ private void OnGhostBoo(Entity entity, ref GhostBooEvent args)
+ {
+ // Only activate sometimes, so groups don't all trigger together
+ if (!_random.Prob(entity.Comp.SpeakChance))
+ return;
+
+ var curTime = _timing.CurTime;
+ // Enforce a delay between messages to prevent spam
+ if (curTime < entity.Comp.NextSpeakTime)
+ return;
+
+ if (!_proto.TryIndex(entity.Comp.MessageSet, out var messages))
+ return;
+
+ // Grab a random localized message from the set
+ var message = _random.Pick(messages);
+ // Chatcode moment: messages starting with '.' are considered radio messages unless prefixed with '>'
+ // So this is a stupid trick to make the "...Oooo"-style messages work.
+ message = '>' + message;
+ // Say the message
+ _chat.TrySendInGameICMessage(entity, message, InGameICChatType.Speak, hideChat: true);
+
+ // Set the delay for the next message
+ entity.Comp.NextSpeakTime = curTime + entity.Comp.Cooldown;
+
+ args.Handled = true;
+ }
+}
diff --git a/Resources/Locale/en-US/ghost/spooky-speaker.ftl b/Resources/Locale/en-US/ghost/spooky-speaker.ftl
new file mode 100644
index 0000000000..9c962eee77
--- /dev/null
+++ b/Resources/Locale/en-US/ghost/spooky-speaker.ftl
@@ -0,0 +1,18 @@
+spooky-speaker-generic-1 = ...ooOoooOOoooo...
+spooky-speaker-generic-2 = ...can anyone hear me...?
+spooky-speaker-generic-3 = ...join us...
+spooky-speaker-generic-4 = ...come play with us...
+spooky-speaker-generic-5 = KkkhhkhKhhkhkKk
+spooky-speaker-generic-6 = Khhggkkghkk
+spooky-speaker-generic-7 = khhkkkkKkhkkHk
+spooky-speaker-generic-8 = ...
+spooky-speaker-generic-9 = ...h-h-hello...?
+spooky-speaker-generic-10 = Bzzzt
+spooky-speaker-generic-11 = Weh
+spooky-speaker-generic-12 = TREMBLE, MORTALS!
+spooky-speaker-generic-13 = 4444444444
+spooky-speaker-generic-14 = ...I found you...
+
+spooky-speaker-recycler-1 = I HUNGER
+spooky-speaker-recycler-2 = MORE! GIVE ME MORE!
+spooky-speaker-recycler-3 = FEED ME
diff --git a/Resources/Prototypes/Datasets/spooky_speakers.yml b/Resources/Prototypes/Datasets/spooky_speakers.yml
new file mode 100644
index 0000000000..a64ab0a80e
--- /dev/null
+++ b/Resources/Prototypes/Datasets/spooky_speakers.yml
@@ -0,0 +1,11 @@
+- type: localizedDataset
+ id: SpookySpeakerMessagesGeneric
+ values:
+ prefix: spooky-speaker-generic-
+ count: 14
+
+- type: localizedDataset
+ id: SpookySpeakerMessagesRecycler
+ values:
+ prefix: spooky-speaker-recycler-
+ count: 3
diff --git a/Resources/Prototypes/Entities/Structures/Machines/Computers/arcades.yml b/Resources/Prototypes/Entities/Structures/Machines/Computers/arcades.yml
index d7f0297101..d57e1c4bf8 100644
--- a/Resources/Prototypes/Entities/Structures/Machines/Computers/arcades.yml
+++ b/Resources/Prototypes/Entities/Structures/Machines/Computers/arcades.yml
@@ -36,6 +36,9 @@
- type: Speech
speechVerb: Robotic
speechSounds: Vending
+ - type: SpookySpeaker
+ messageSet: SpookySpeakerMessagesGeneric
+ speakChance: 0.2
- type: Anchorable
- type: Pullable
- type: StaticPrice
diff --git a/Resources/Prototypes/Entities/Structures/Machines/recycler.yml b/Resources/Prototypes/Entities/Structures/Machines/recycler.yml
index f62923fe2a..2058c3c050 100644
--- a/Resources/Prototypes/Entities/Structures/Machines/recycler.yml
+++ b/Resources/Prototypes/Entities/Structures/Machines/recycler.yml
@@ -108,3 +108,10 @@
interactFailureString: petting-failure-generic
interactSuccessSound:
path: /Audio/Items/drill_hit.ogg
+ - type: SpookySpeaker
+ speakChance: 0.1
+ cooldown: 120
+ messageSet: SpookySpeakerMessagesRecycler
+ - type: Speech
+ speechVerb: Robotic
+ speechSounds: SyndieBorg
diff --git a/Resources/Prototypes/Entities/Structures/Machines/vending_machines.yml b/Resources/Prototypes/Entities/Structures/Machines/vending_machines.yml
index 108693242c..f66c58f199 100644
--- a/Resources/Prototypes/Entities/Structures/Machines/vending_machines.yml
+++ b/Resources/Prototypes/Entities/Structures/Machines/vending_machines.yml
@@ -78,6 +78,9 @@
- type: Speech
speechVerb: Robotic
speechSounds: Vending
+ - type: SpookySpeaker
+ messageSet: SpookySpeakerMessagesGeneric
+ speakChance: 0.2
- type: IntrinsicRadioReceiver
- type: ActiveRadio
channels: