diff --git a/unity-client/Assets/Scripts/UI/ScannerController.cs b/unity-client/Assets/Scripts/UI/ScannerController.cs
new file mode 100644
index 0000000..3f6b748
--- /dev/null
+++ b/unity-client/Assets/Scripts/UI/ScannerController.cs
@@ -0,0 +1,228 @@
+using System;
+using System.Collections;
+using System.Collections.Generic;
+using UnityEngine;
+using UnityEngine.UI;
+using UnityEngine.SceneManagement;
+using TMPro;
+using Newtonsoft.Json;
+using CommanderAILab.Models;
+using CommanderAILab.Services;
+
+namespace CommanderAILab.UI
+{
+ ///
+ /// Scanner scene — live camera feed, capture frame, POST /api/scanner/scan,
+ /// display result card overlay, add to collection or deck.
+ ///
+ public class ScannerController : MonoBehaviour
+ {
+ [Header("Navigation")]
+ [SerializeField] private Button backButton;
+
+ [Header("Camera Feed")]
+ [SerializeField] private RawImage cameraFeedImage;
+ [SerializeField] private Button captureButton;
+ [SerializeField] private Button toggleCameraButton;
+ [SerializeField] private TMP_Text cameraStatusText;
+ [SerializeField] private GameObject scanOverlay; // animated scan line
+
+ [Header("Result Panel")]
+ [SerializeField] private GameObject resultPanel;
+ [SerializeField] private Image resultCardImage;
+ [SerializeField] private TMP_Text resultCardName;
+ [SerializeField] private TMP_Text resultManaCost;
+ [SerializeField] private TMP_Text resultType;
+ [SerializeField] private TMP_Text resultSet;
+ [SerializeField] private TMP_Text resultOracleText;
+ [SerializeField] private TMP_Text resultConfidence;
+ [SerializeField] private Button addToCollectionButton;
+ [SerializeField] private Button addToDeckButton;
+ [SerializeField] private Button scanAgainButton;
+
+ [Header("Scan History")]
+ [SerializeField] private Transform historyParent;
+ [SerializeField] private GameObject historyRowPrefab;
+
+ [Header("Error")]
+ [SerializeField] private GameObject errorPanel;
+ [SerializeField] private TMP_Text errorText;
+ [SerializeField] private Button retryButton;
+
+ private WebCamTexture _webcam;
+ private int _cameraIndex = 0;
+ private CardModel _scannedCard;
+ private bool _isScanning = false;
+ private readonly List _history = new();
+ private readonly List _historyRows = new();
+
+ private void Start()
+ {
+ backButton.onClick.AddListener(OnBack);
+ captureButton.onClick.AddListener(OnCapture);
+ toggleCameraButton.onClick.AddListener(ToggleCamera);
+ addToCollectionButton.onClick.AddListener(OnAddToCollection);
+ addToDeckButton.onClick.AddListener(OnAddToDeck);
+ scanAgainButton.onClick.AddListener(OnScanAgain);
+ retryButton.onClick.AddListener(OnCapture);
+
+ resultPanel.SetActive(false);
+ errorPanel.SetActive(false);
+ scanOverlay.SetActive(false);
+
+ StartCamera(_cameraIndex);
+ }
+
+ private void OnDestroy()
+ {
+ if (_webcam != null && _webcam.isPlaying) _webcam.Stop();
+ }
+
+ // ── Camera ────────────────────────────────────────────────────────
+ private void StartCamera(int idx)
+ {
+ if (_webcam != null && _webcam.isPlaying) _webcam.Stop();
+
+ var devices = WebCamTexture.devices;
+ if (devices.Length == 0)
+ {
+ cameraStatusText.text = "No camera found";
+ captureButton.interactable = false;
+ return;
+ }
+
+ idx = Mathf.Clamp(idx, 0, devices.Length - 1);
+ _webcam = new WebCamTexture(devices[idx].name, 1280, 720, 30);
+ _webcam.Play();
+ cameraFeedImage.texture = _webcam;
+ cameraStatusText.text = $"Camera: {devices[idx].name}";
+ captureButton.interactable = true;
+ }
+
+ private void ToggleCamera()
+ {
+ var devices = WebCamTexture.devices;
+ if (devices.Length < 2) return;
+ _cameraIndex = (_cameraIndex + 1) % devices.Length;
+ StartCamera(_cameraIndex);
+ }
+
+ // ── Capture & Scan ────────────────────────────────────────────────
+ private void OnCapture()
+ {
+ if (_isScanning || _webcam == null || !_webcam.isPlaying) return;
+ _isScanning = true;
+ errorPanel.SetActive(false);
+ resultPanel.SetActive(false);
+ scanOverlay.SetActive(true);
+ captureButton.interactable = false;
+
+ // Grab frame and encode to JPEG
+ var tex = new Texture2D(_webcam.width, _webcam.height);
+ tex.SetPixels(_webcam.GetPixels());
+ tex.Apply();
+ byte[] jpegBytes = tex.EncodeToJPG(85);
+ Destroy(tex);
+
+ ApiClient.Instance.ScanCard(jpegBytes,
+ json =>
+ {
+ scanOverlay.SetActive(false);
+ _isScanning = false;
+ captureButton.interactable = true;
+ try
+ {
+ var result = JsonConvert.DeserializeObject(json);
+ if (result?.card != null) ShowResult(result.card, result.confidence);
+ else ShowError("Card not recognized. Try again.");
+ }
+ catch (Exception e) { ShowError($"Parse error: {e.Message}"); }
+ },
+ err =>
+ {
+ scanOverlay.SetActive(false);
+ _isScanning = false;
+ captureButton.interactable = true;
+ ShowError($"Scan failed:\n{err}");
+ });
+ }
+
+ // ── Result ────────────────────────────────────────────────────────
+ private void ShowResult(CardModel card, float confidence)
+ {
+ _scannedCard = card;
+ resultPanel.SetActive(true);
+
+ resultCardName.text = card.name;
+ resultManaCost.text = card.manaCost;
+ resultType.text = card.typeLine;
+ resultSet.text = card.set;
+ resultOracleText.text = card.oracleText;
+ resultConfidence.text = $"Confidence: {confidence:P0}";
+
+ if (!string.IsNullOrEmpty(card.imageUrl))
+ ImageCache.Instance.GetSprite(card.imageUrl, s =>
+ {
+ if (resultCardImage != null) resultCardImage.sprite = s;
+ });
+
+ AddToHistory(card);
+ }
+
+ private void AddToHistory(CardModel card)
+ {
+ _history.Add(card);
+ var row = Instantiate(historyRowPrefab, historyParent);
+ _historyRows.Add(row);
+ var lbl = row.GetComponentInChildren();
+ if (lbl) lbl.text = card.name;
+ var btn = row.GetComponentInChildren