diff --git a/SVSim.EmulatedEntrypoint/Controllers/TutorialController.cs b/SVSim.EmulatedEntrypoint/Controllers/TutorialController.cs
new file mode 100644
index 0000000..7106d15
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Controllers/TutorialController.cs
@@ -0,0 +1,19 @@
+using Microsoft.AspNetCore.Mvc;
+using SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Tutorial;
+
+namespace SVSim.EmulatedEntrypoint.Controllers;
+
+///
+/// Tutorial step bookkeeping. The tutorial itself runs entirely client-side
+/// (StoryTutorial*BattleMgr per class); the server only persists step transitions.
+///
+public class TutorialController : SVSimController
+{
+ [HttpPost("update_action")]
+ public IActionResult UpdateAction([FromBody] TutorialUpdateActionRequest request)
+ {
+ // Fire-and-forget. Client uses SkipAllNetworkChecks; response body is ignored.
+ // We still emit an empty object so the translation middleware has a `data` payload to wrap.
+ return new JsonResult(new { });
+ }
+}
diff --git a/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Tutorial/TutorialUpdateActionRequest.cs b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Tutorial/TutorialUpdateActionRequest.cs
new file mode 100644
index 0000000..7ef1cc5
--- /dev/null
+++ b/SVSim.EmulatedEntrypoint/Models/Dtos/Requests/Tutorial/TutorialUpdateActionRequest.cs
@@ -0,0 +1,22 @@
+using System.Text.Json.Serialization;
+using MessagePack;
+using SVSim.EmulatedEntrypoint.Models.Dtos.Requests;
+
+namespace SVSim.EmulatedEntrypoint.Models.Dtos.Requests.Tutorial;
+
+///
+/// POST /tutorial/update_action — fire-and-forget sub-step tracking.
+/// Client task: Wizard/TutorialUpdateActionTask.cs. SkipAllNetworkChecks is on,
+/// so any return value (including failures) is silently ignored.
+///
+[MessagePackObject]
+public class TutorialUpdateActionRequest : BaseRequest
+{
+ [JsonPropertyName("tutorial_step")]
+ [Key("tutorial_step")]
+ public int TutorialStep { get; set; }
+
+ [JsonPropertyName("tutorial_action_number")]
+ [Key("tutorial_action_number")]
+ public int TutorialActionNumber { get; set; }
+}
diff --git a/SVSim.UnitTests/Controllers/TutorialControllerTests.cs b/SVSim.UnitTests/Controllers/TutorialControllerTests.cs
new file mode 100644
index 0000000..ad11db4
--- /dev/null
+++ b/SVSim.UnitTests/Controllers/TutorialControllerTests.cs
@@ -0,0 +1,36 @@
+using System.Net;
+using System.Text;
+using System.Text.Json;
+using NUnit.Framework;
+using SVSim.UnitTests.Infrastructure;
+
+namespace SVSim.UnitTests.Controllers;
+
+public class TutorialControllerTests
+{
+ [Test]
+ public async Task UpdateAction_returns_result_code_1_with_empty_data()
+ {
+ using var factory = new SVSimTestFactory();
+ long viewerId = await factory.SeedViewerAsync(tutorialState: 0);
+ using var client = factory.CreateAuthenticatedClient(viewerId);
+
+ // tutorial_step and tutorial_action_number are fire-and-forget bookkeeping fields;
+ // send representative values from the live capture (step=1, action=2).
+ var requestJson =
+ """{"tutorial_step":1,"tutorial_action_number":2,"viewer_id":"0","steam_id":0,"steam_session_ticket":""}""";
+
+ var response = await client.PostAsync("/tutorial/update_action",
+ new StringContent(requestJson, Encoding.UTF8, "application/json"));
+
+ Assert.That(response.StatusCode, Is.EqualTo(HttpStatusCode.OK));
+ var body = await response.Content.ReadAsStringAsync();
+
+ // Controllers return the INNER data payload; envelope is middleware's job.
+ // For the no-op shape the action returns an empty object.
+ using var doc = JsonDocument.Parse(body);
+ Assert.That(doc.RootElement.ValueKind, Is.EqualTo(JsonValueKind.Object));
+ Assert.That(doc.RootElement.EnumerateObject().Count(), Is.EqualTo(0),
+ "update_action returns empty data — client uses SkipAllNetworkChecks and reads nothing.");
+ }
+}