diff --git a/.github/workflows/pr-preview.yml b/.github/workflows/pr-preview.yml
new file mode 100644
index 0000000..d2514e0
--- /dev/null
+++ b/.github/workflows/pr-preview.yml
@@ -0,0 +1,83 @@
+name: PR Preview Screenshot
+
+# Loads the default location in Unity play mode, renders a screenshot, and
+# posts a direct download link as a comment on the pull request.
+#
+# Required repository secrets
+# ────────────────────────────
+# UNITY_LICENSE – contents of a valid Unity .ulf license file
+# UNITY_EMAIL – Unity account e-mail
+# UNITY_PASSWORD – Unity account password
+
+on:
+ pull_request:
+ branches: [ main ]
+
+jobs:
+ screenshot:
+ name: Capture play-mode screenshot
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ issues: write
+ pull-requests: write
+
+ steps:
+ - name: Checkout repository
+ uses: actions/checkout@v4
+ with:
+ lfs: true
+
+ - name: Cache Unity Library
+ uses: actions/cache@v4
+ with:
+ path: Library
+ key: Library-screenshot-${{ hashFiles('Assets/**', 'Packages/**', 'ProjectSettings/**') }}
+ restore-keys: |
+ Library-screenshot-
+ Library-
+
+ - name: Run play-mode screenshot test
+ uses: game-ci/unity-test-runner@v4
+ id: screenshot-test
+ env:
+ UNITY_LICENSE: ${{ secrets.UNITY_LICENSE }}
+ UNITY_EMAIL: ${{ secrets.UNITY_EMAIL }}
+ UNITY_PASSWORD: ${{ secrets.UNITY_PASSWORD }}
+ with:
+ testMode: playMode
+ testFilter: VectorRoad.Tests.PlayMode.SceneScreenshotTests
+ artifactsPath: TestResults/playMode
+ githubToken: ${{ secrets.GITHUB_TOKEN }}
+ checkName: PR Preview Screenshot
+
+ - name: Upload screenshot artifact
+ id: upload-screenshot
+ uses: actions/upload-artifact@v4
+ if: always()
+ with:
+ name: pr-preview-screenshot
+ path: Screenshots/pr-preview.png
+ if-no-files-found: warn
+ retention-days: 14
+
+ - name: Post PR comment with download link
+ uses: actions/github-script@v7
+ if: always()
+ env:
+ ARTIFACT_URL: ${{ steps.upload-screenshot.outputs.artifact-url }}
+ with:
+ script: |
+ const artifactUrl = process.env.ARTIFACT_URL;
+ const runUrl = `https://github.com/${context.repo.owner}/${context.repo.repo}/actions/runs/${context.runId}`;
+
+ const body = artifactUrl
+ ? `## 📸 PR Preview Screenshot\n\nA screenshot of the default location in play mode was captured for this PR.\n\n**[⬇️ Download Screenshot](${artifactUrl})**\n\n> Rendered at [workflow run](${runUrl})`
+ : `## 📸 PR Preview Screenshot\n\n⚠️ The screenshot could not be captured for this PR.\n\nSee the [workflow run](${runUrl}) for details.`;
+
+ github.rest.issues.createComment({
+ issue_number: context.issue.number,
+ owner: context.repo.owner,
+ repo: context.repo.repo,
+ body,
+ });
diff --git a/Assets/Tests/PlayMode/SceneScreenshotTests.cs b/Assets/Tests/PlayMode/SceneScreenshotTests.cs
new file mode 100644
index 0000000..a8db0cf
--- /dev/null
+++ b/Assets/Tests/PlayMode/SceneScreenshotTests.cs
@@ -0,0 +1,113 @@
+using System.Collections;
+using System.IO;
+using NUnit.Framework;
+using UnityEngine;
+using UnityEngine.SceneManagement;
+using UnityEngine.TestTools;
+using VectorRoad.Core;
+
+namespace VectorRoad.Tests.PlayMode
+{
+ ///
+ /// Play-mode test that loads the default ProofOfConcept scene, waits
+ /// for the map-build pipeline to reach the
+ /// state, then renders a PNG screenshot to Screenshots/pr-preview.png
+ /// at the project root.
+ ///
+ ///
+ /// Designed to run in GitHub Actions via the pr-preview.yml workflow.
+ /// The screenshot artifact is uploaded and linked in a PR comment so
+ /// reviewers can see the rendered result at a glance.
+ ///
+ ///
+ ///
+ /// The startup menu is bypassed automatically by advancing the
+ /// state to
+ /// immediately after the scene loads.
+ ///
+ ///
+ public class SceneScreenshotTests
+ {
+ private const string SceneName = "ProofOfConcept";
+ private const int ScreenshotWidth = 1920;
+ private const int ScreenshotHeight = 1080;
+
+ ///
+ /// Loads the default location, waits for level generation to complete,
+ /// and saves a screenshot to Screenshots/pr-preview.png.
+ ///
+ [UnityTest]
+ [Timeout(300000)] // 5 minutes – map build can take a while in CI
+ public IEnumerator DefaultLocation_RendersScene()
+ {
+ yield return SceneManager.LoadSceneAsync(SceneName);
+
+ // Allow Awake/Start to run on all objects in the loaded scene.
+ yield return null;
+
+ // The MapSceneBuilder waits for the GameManager to leave MainMenu
+ // before it starts loading map data. Advance the state here to
+ // skip the interactive startup menu in automated runs.
+ var gm = GameManager.Instance;
+ if (gm != null && gm.CurrentState == GameState.MainMenu)
+ gm.SetState(GameState.LoadingMap);
+
+ // Wait until the map build pipeline signals that the level is ready.
+ float elapsed = 0f;
+ const float mapLoadTimeout = 240f; // seconds
+ while (elapsed < mapLoadTimeout)
+ {
+ var instance = GameManager.Instance;
+ if (instance == null)
+ Assert.Fail("GameManager.Instance became null while waiting for map load.");
+ if (instance.CurrentState == GameState.Racing)
+ break;
+ elapsed += Time.deltaTime;
+ yield return null;
+ }
+
+ // Give the physics engine and ChaseCam a few seconds to settle.
+ // The vehicle is spawned 2 m above the road surface and needs time to
+ // drop onto it; the ChaseCam uses SmoothDamp so it also needs several
+ // frames to move from its initial position to behind the vehicle.
+ yield return new WaitForSeconds(3f);
+
+ // Find any active camera to render from. Camera.main returns the
+ // camera tagged "MainCamera", which is the expected render camera in
+ // the ProofOfConcept scene. FindFirstObjectByType is a safe fallback
+ // for scenes where the main camera tag has not been set.
+ var camera = Camera.main ?? Object.FindFirstObjectByType();
+ Assert.IsNotNull(camera, "No Camera was found in the scene.");
+
+ // Render the scene to a RenderTexture so the capture works reliably
+ // in headless / batch mode (no display required).
+ var rt = new RenderTexture(ScreenshotWidth, ScreenshotHeight, 24);
+ var prevTarget = camera.targetTexture;
+ camera.targetTexture = rt;
+ camera.Render();
+
+ var tex = new Texture2D(ScreenshotWidth, ScreenshotHeight,
+ TextureFormat.RGB24, false);
+ RenderTexture.active = rt;
+ tex.ReadPixels(new Rect(0, 0, ScreenshotWidth, ScreenshotHeight), 0, 0);
+ tex.Apply();
+
+ // Save to /Screenshots/pr-preview.png so the workflow
+ // can locate and upload the file as an artifact.
+ string screenshotDir = Path.GetFullPath(
+ Path.Combine(Application.dataPath, "..", "Screenshots"));
+ Directory.CreateDirectory(screenshotDir);
+ string screenshotPath = Path.Combine(screenshotDir, "pr-preview.png");
+ File.WriteAllBytes(screenshotPath, tex.EncodeToPNG());
+
+ // Restore state and release GPU resources.
+ camera.targetTexture = prevTarget;
+ RenderTexture.active = null;
+ Object.Destroy(rt);
+ Object.Destroy(tex);
+
+ Assert.IsTrue(File.Exists(screenshotPath),
+ $"Screenshot was not saved to {screenshotPath}");
+ }
+ }
+}
diff --git a/Assets/Tests/PlayMode/SceneScreenshotTests.cs.meta b/Assets/Tests/PlayMode/SceneScreenshotTests.cs.meta
new file mode 100644
index 0000000..6dad483
--- /dev/null
+++ b/Assets/Tests/PlayMode/SceneScreenshotTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: d053d836326f403683ba056925896fd7
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant: