diff --git a/SVSim.BattleNode/Sessions/Participants/NoOpBotParticipant.cs b/SVSim.BattleNode/Sessions/Participants/NoOpBotParticipant.cs
new file mode 100644
index 0000000..3d5048f
--- /dev/null
+++ b/SVSim.BattleNode/Sessions/Participants/NoOpBotParticipant.cs
@@ -0,0 +1,35 @@
+using SVSim.BattleNode.Bridge;
+using SVSim.BattleNode.Lifecycle;
+using SVSim.BattleNode.Protocol;
+
+namespace SVSim.BattleNode.Sessions.Participants;
+
+///
+/// Silent participant — produces no frames, swallows everything pushed to it.
+/// Used as the "other" participant in sessions, where
+/// the real opponent runs in the client and the server has no opponent-side state
+/// to model. ViewerId is ;
+/// Context is a fixed stub (irrelevant — never read because no frames are pushed
+/// to the other side).
+///
+public sealed class NoOpBotParticipant : IBattleParticipant
+{
+ public long ViewerId => ScriptedLifecycle.FakeOpponentViewerId;
+ public MatchContext Context { get; } = new(
+ SelfDeckCardIds: Array.Empty(),
+ ClassId: "0", CharaId: "0", CardMasterName: "card_master_node_10015",
+ CountryCode: "", UserName: "Bot", SleeveId: "0",
+ EmblemId: "0", DegreeId: "0", FieldId: 0, IsOfficial: 0,
+ BattleType: 0);
+
+ public event Func? FrameEmitted;
+
+ public Task PushAsync(MsgEnvelope envelope, bool noStock, CancellationToken ct) => Task.CompletedTask;
+ public Task RunAsync(CancellationToken ct) => Task.CompletedTask;
+ public Task TerminateAsync(BattleFinishReason reason) => Task.CompletedTask;
+ public ValueTask DisposeAsync() => ValueTask.CompletedTask;
+
+ // Suppress unused-event warning — FrameEmitted is declared by the interface contract;
+ // intentionally never invoked.
+ private void Touch() => FrameEmitted?.Invoke(null!, default);
+}
diff --git a/SVSim.UnitTests/BattleNode/Sessions/Participants/NoOpBotParticipantTests.cs b/SVSim.UnitTests/BattleNode/Sessions/Participants/NoOpBotParticipantTests.cs
new file mode 100644
index 0000000..153bdb7
--- /dev/null
+++ b/SVSim.UnitTests/BattleNode/Sessions/Participants/NoOpBotParticipantTests.cs
@@ -0,0 +1,44 @@
+using NUnit.Framework;
+using SVSim.BattleNode.Bridge;
+using SVSim.BattleNode.Protocol;
+using SVSim.BattleNode.Protocol.Bodies;
+using SVSim.BattleNode.Sessions;
+using SVSim.BattleNode.Sessions.Participants;
+
+namespace SVSim.UnitTests.BattleNode.Sessions.Participants;
+
+[TestFixture]
+public class NoOpBotParticipantTests
+{
+ [Test]
+ public void PushAsync_swallows_without_firing_FrameEmitted()
+ {
+ var p = new NoOpBotParticipant();
+ var fired = 0;
+ p.FrameEmitted += (_, _) => { fired++; return Task.CompletedTask; };
+
+ var env = new MsgEnvelope(
+ NetworkBattleUri.TurnEnd, ViewerId: 1, Uuid: "u", Bid: null, Try: 0,
+ Cat: EmitCategory.Battle, PubSeq: null, PlaySeq: null,
+ Body: new ResultCodeOnlyBody());
+
+ Assert.DoesNotThrowAsync(() => p.PushAsync(env, noStock: false, CancellationToken.None));
+ Assert.That(fired, Is.EqualTo(0));
+ }
+
+ [Test]
+ public async Task RunAsync_returns_immediately()
+ {
+ var p = new NoOpBotParticipant();
+ await p.RunAsync(CancellationToken.None);
+ // If we got here, it returned.
+ Assert.Pass();
+ }
+
+ [Test]
+ public void ViewerId_is_FakeOpponent()
+ {
+ var p = new NoOpBotParticipant();
+ Assert.That(p.ViewerId, Is.EqualTo(SVSim.BattleNode.Lifecycle.ScriptedLifecycle.FakeOpponentViewerId));
+ }
+}