Code Sketch


yoii granny multiplayer
By: Mhalsakant School
Category: Programming
import javax.swing._
import java.awt._
import java.awt.event._
import java.awt.image.BufferedImage
import java.awt.image.DataBufferInt
import java.net._
import java.io._
import scala.collection.mutable.ArrayBuffer
import scala.collection.mutable

/*
 * OLD HOUSE 3D - improved standalone Scala/Swing prototype.
 * Features: triangle rasterizer + depth buffer, near-plane clipping,
 * hinged opening doors, stairs with changing player height, upstairs
 * gallery, WASD movement, mouse look, flashlight, hiding, enemy AI,
 * LAN two-player room discovery, remote avatar sync, shared quest items,
 * garage / bunker / dock zones, car and boat escape objectives.
 *
 * This is an original low-poly prototype, not the original Granny game.
 * It uses no third-party game-engine libraries, but it is not a
 * commercial-quality full 3D engine.
 */
object OldHouse3DImproved {
  case class V3(x: Double, y: Double, z: Double)
  case class CV(x: Double, y: Double, z: Double)
  case class Tri(a: V3, b: V3, c: V3, color: Color)
  case class Box(x: Double, y: Double, z: Double,
                 sx: Double, sy: Double, sz: Double,
                 color: Color, solid: Boolean)

  final class Door(val x: Double, val z: Double, val width: Double,
                   val plane: String, val hingeSide: Double,
                   val yBase: Double, val height: Double,
                   val locked: Boolean, val isExit: Boolean,
                   val name: String) {
    var angle = 0.0
    var target = 0.0
  }

  case class Pickup(name: String, x: Double, z: Double,
                    upper: Boolean, flag: String, color: Color)

  case class RemotePlayer(x: Double, z: Double, yaw: Double,
                          upper: Boolean, role: String, active: Boolean)

  // LAN discovery uses UDP; the actual shared player stream uses TCP.
  object LANLink {
    val TCP_PORT = 47888
    val UDP_PORT = 47887
    @volatile private var running = false
    @volatile private var peerSocket: Socket = _
    @volatile private var serverSocket: ServerSocket = _
    @volatile private var writer: PrintWriter = _
    @volatile var connected = false
    @volatile var lastError = ""

    private def attach(s: Socket): Unit = synchronized {
      peerSocket = s
      s.setTcpNoDelay(true)
      writer = new PrintWriter(new OutputStreamWriter(s.getOutputStream, "UTF-8"), true)
      connected = true
      lastError = ""
      val reader = new BufferedReader(new InputStreamReader(s.getInputStream, "UTF-8"))
      val t = new Thread(new Runnable {
        def run(): Unit = {
          try {
            var line = reader.readLine()
            while (line != null && running) {
              receiveNetworkState(line)
              line = reader.readLine()
            }
          } catch { case _: Throwable => () }
          connected = false
          remoteActive = false
          say("MULTIPLAYER CONNECTION CLOSED", 4.0)
        }
      }, "OldHouse-LAN-Reader")
      t.setDaemon(true)
      t.start()
    }

    def startHost(): Unit = {
      running = true
      try {
        serverSocket = new ServerSocket(TCP_PORT)
        val acceptThread = new Thread(new Runnable {
          def run(): Unit = {
            try {
              val s = serverSocket.accept()
              attach(s)
              say("PLAYER 2 CONNECTED TO THE ROOM!", 5.0)
            } catch { case e: Throwable => lastError = e.getMessage }
          }
        }, "OldHouse-LAN-Host")
        acceptThread.setDaemon(true)
        acceptThread.start()

        val beaconThread = new Thread(new Runnable {
          def run(): Unit = {
            var ds: DatagramSocket = null
            try {
              ds = new DatagramSocket()
              ds.setBroadcast(true)
              val bytes = ("OLDHOUSE3D|" + TCP_PORT).getBytes("UTF-8")
              val packet = new DatagramPacket(bytes, bytes.length,
                InetAddress.getByName("255.255.255.255"), UDP_PORT)
              while (running && !connected) {
                try { ds.send(packet) } catch { case _: Throwable => () }
                Thread.sleep(1200L)
              }
            } catch { case _: Throwable => () }
            finally { if (ds != null) ds.close() }
          }
        }, "OldHouse-LAN-Discovery")
        beaconThread.setDaemon(true)
        beaconThread.start()
      } catch {
        case e: Throwable =>
          lastError = Option(e.getMessage).getOrElse("Could not open LAN port")
          say("LAN HOST ERROR: " + lastError, 8.0)
      }
    }

    def discoverRoom(waitMs: Int): String = {
      var ds: DatagramSocket = null
      try {
        ds = new DatagramSocket(UDP_PORT)
        ds.setSoTimeout(650)
        val endAt = System.currentTimeMillis() + waitMs
        val buf = new Array[Byte](256)
        while (System.currentTimeMillis() < endAt) {
          val packet = new DatagramPacket(buf, buf.length)
          try {
            ds.receive(packet)
            val msg = new String(packet.getData, 0, packet.getLength, "UTF-8")
            if (msg.startsWith("OLDHOUSE3D|")) return packet.getAddress.getHostAddress
          } catch { case _: java.net.SocketTimeoutException => () }
        }
      } catch { case e: Throwable => lastError = Option(e.getMessage).getOrElse("Discovery failed") }
      finally { if (ds != null) ds.close() }
      ""
    }

    def join(host: String): Boolean = {
      running = true
      try {
        val s = new Socket()
        s.connect(new InetSocketAddress(host.trim, TCP_PORT), 5000)
        attach(s)
        true
      } catch {
        case e: Throwable =>
          lastError = Option(e.getMessage).getOrElse("Could not join room")
          connected = false
          false
      }
    }

    def send(line: String): Unit = {
      val w = writer
      if (connected && w != null) synchronized {
        try { w.println(line); if (w.checkError()) connected = false }
        catch { case _: Throwable => connected = false }
      }
    }

    def close(): Unit = {
      running = false
      connected = false
      remoteActive = false
      try { if (peerSocket != null) peerSocket.close() } catch { case _: Throwable => () }
      try { if (serverSocket != null) serverSocket.close() } catch { case _: Throwable => () }
      peerSocket = null; serverSocket = null; writer = null
    }
  }

  val SW = 1000
  val SH = 620
  val NEAR = 0.12
  val FOCAL = 760.0
  val WALL = new Color(86, 75, 64)
  val WALL_ALT = new Color(110, 91, 72)
  val FLOOR = new Color(76, 60, 46)
  val WOOD = new Color(91, 57, 36)
  val DARK_WOOD = new Color(46, 31, 24)

  val triangles = ArrayBuffer[Tri]()
  val solids = ArrayBuffer[Box]()
  val doors = ArrayBuffer[Door]()
  val keys = mutable.HashSet[Int]()

  var px = 3.2
  var pz = 4.15
  var yaw = math.Pi
  var pitch = 0.0
  var onUpperFloor = false
  var wakeProgress = 0.0
  var started = false
  @volatile var gameOver = false
  @volatile var escaped = false
  var hidden = false
  var hidePlace = ""
  @volatile var hasKey = false
  var hasBomb = false
  var bombThrown = false
  var bombX = 0.0
  var bombZ = 0.0
  var bombY = 0.25
  var bombDX = 0.0
  var bombDZ = 0.0
  var bombFuse = 0.0
  var bombBlastTimer = 0.0
  @volatile var enemyStunnedTimer = 0.0
  var secretUnlocked = false
  var secretLootTaken = false
  var flashlight = true
  var showMap = false
  @volatile var day = 1

  var gameMode = "SINGLE"
  var playerRole = "PLAYER"
  var enemyType = "GRANNY"
  @volatile var remoteRole = "PLAYER"
  @volatile var remoteActive = false
  @volatile var remoteX = 4.8
  @volatile var remoteZ = 4.8
  @volatile var remoteYaw = 0.0
  @volatile var remoteUpper = false
  @volatile var remoteHidden = false
  @volatile var hasFuel = false
  @volatile var hasBattery = false
  @volatile var hasRope = false
  @volatile var hasBoatKey = false
  @volatile var hasWrench = false
  @volatile var hasCode = false
  @volatile var carEscaped = false
  @volatile var boatEscaped = false
  var netElapsed = 0.0
  @volatile var selectedEscape = ""

  val keyX = 9.35
  val keyZ = 3.35
  val wardrobeX = 1.25
  val wardrobeZ = 4.7
  val bedX = 2.3
  val bedZ = 2.1
  val vaseX = 4.65
  val vaseZ = 2.35
  // Discoverable shelf and the hidden room behind its secret panel.
  val clueShelfX = 3.55
  val clueShelfZ = 10.55
  val secretShelfX = 3.15
  val secretShelfZ = 14.65
  val bathroomDoorX = 18.65
  val bathroomDoorZ = 6.0
  val ladderX = 14.80
  val ladderZ = 14.00
  var vaseY = 0.9
  var vaseVel = 0.0
  var vaseFalling = false
  var vaseBroken = false
  var noiseX = vaseX
  var noiseZ = vaseZ
  var noiseTimer = 0.0

  // Original pursuer character: not a copied Granny model.
  @volatile var enemyX = 9.4
  @volatile var enemyZ = 8.3
  @volatile var enemyUpper = false
  var enemyTargetX = enemyX
  var enemyTargetZ = enemyZ
  var aiTimer = 0.0
  var patrolIndex = 0
  val patrol = Array((9.4, 8.3), (10.3, 10.0), (9.2, 14.0),
                     (15.8, 8.0), (19.0, 9.0))

  // The six items are placed in different rooms / levels. Their positions
  // stay fixed in this prototype; multiplayer shares collected objectives.
  val pickups = Seq(
    Pickup("FUEL CAN", 28.7, 4.9, false, "FUEL", new Color(218, 104, 35)),
    Pickup("BATTERY", 29.2, 15.0, false, "BATTERY", new Color(80, 185, 215)),
    Pickup("ROPE", 31.2, 18.4, false, "ROPE", new Color(191, 164, 112)),
    Pickup("BOAT KEY", 28.7, 18.3, false, "BOATKEY", new Color(238, 194, 60)),
    Pickup("WRENCH", 31.6, 14.6, false, "WRENCH", new Color(155, 165, 174)),
    Pickup("GARAGE CODE", 21.0, 12.5, true, "CODE", new Color(177, 105, 225))
  )

  var message = "DAY 1 - PRESS ENTER TO WAKE UP"
  var messageTimer = 5.0
  var lastMouseX = -1
  var lastMouseY = -1
  var gameTimer: Timer = _
  var frame: JFrame = _
  var panel: GamePanel = _
  var image: BufferedImage = _
  var pixels: Array[Int] = _
  var zBuffer: Array[Double] = _

  def clamp(v: Double, lo: Double, hi: Double): Double =
    math.max(lo, math.min(hi, v))

  def dist(x1: Double, z1: Double, x2: Double, z2: Double): Double = {
    val dx = x1 - x2
    val dz = z1 - z2
    math.sqrt(dx * dx + dz * dz)
  }

  def shade(c: Color, f0: Double): Color = {
    val f = clamp(f0, 0.0, 1.35)
    new Color(
      clamp(c.getRed * f, 0, 255).toInt,
      clamp(c.getGreen * f, 0, 255).toInt,
      clamp(c.getBlue * f, 0, 255).toInt
    )
  }

  def configureGame(): Unit = {
    val modes = Array[AnyRef]("Single Player", "Multiplayer - Host Room", "Multiplayer - Join Room")
    val modeChoice = JOptionPane.showOptionDialog(null,
      "Choose how you want to play OLD HOUSE 3D", "OLD HOUSE 3D - Game Mode",
      JOptionPane.DEFAULT_OPTION, JOptionPane.QUESTION_MESSAGE, null, modes, modes(0))

    if (modeChoice == 1) {
      gameMode = "HOST"
      playerRole = chooseRole()
      LANLink.startHost()
      JOptionPane.showMessageDialog(null,
        "LAN room is starting on port " + LANLink.TCP_PORT + ".\nKeep this game open, then choose Join Room on the second computer.\nAllow Java through Windows Firewall when asked.\nIf auto-detect fails, run ipconfig on this PC and share its IPv4 Address.",
        "HOST ROOM", JOptionPane.INFORMATION_MESSAGE)
    } else if (modeChoice == 2) {
      gameMode = "CLIENT"
      playerRole = chooseRole()
      val found = LANLink.discoverRoom(4500)
      val host = if (found.nonEmpty) found else {
        val typed = JOptionPane.showInputDialog(null,
          "No room was auto-detected. Enter the HOST computer's local IPv4 address:",
          "Join LAN Room", JOptionPane.QUESTION_MESSAGE)
        Option(typed).getOrElse("").trim
      }
      if (host.nonEmpty && LANLink.join(host)) {
        JOptionPane.showMessageDialog(null, "Connected to " + host + "! Both players can now enter the same house.",
          "ROOM CONNECTED", JOptionPane.INFORMATION_MESSAGE)
      } else {
        gameMode = "SINGLE"
        playerRole = "PLAYER"
        JOptionPane.showMessageDialog(null,
          "Could not connect to the room. Starting Single Player.\n" + LANLink.lastError,
          "CONNECTION FAILED", JOptionPane.WARNING_MESSAGE)
        LANLink.close()
      }
    } else {
      gameMode = "SINGLE"
      playerRole = "PLAYER"
      val enemies = Array[AnyRef]("Granny AI", "Grandpa AI", "Robot AI", "Both - Granny + Grandpa")
      val enemyChoice = JOptionPane.showOptionDialog(null,
        "Choose the computer-controlled enemy", "ENEMY SELECT",
        JOptionPane.DEFAULT_OPTION, JOptionPane.QUESTION_MESSAGE, null, enemies, enemies(0))
      enemyType = if (enemyChoice == 1) "GRANDPA" else if (enemyChoice == 2) "ROBOT" else if (enemyChoice == 3) "BOTH" else "GRANNY"
    }
  }

  def chooseRole(): String = {
    val roles = Array[AnyRef]("Player / Survivor", "Granny", "Grandpa", "Granny + Grandpa (Both)")
    val c = JOptionPane.showOptionDialog(null,
      "Choose your character. The second computer chooses its own character.",
      "CHARACTER SELECT", JOptionPane.DEFAULT_OPTION,
      JOptionPane.QUESTION_MESSAGE, null, roles, roles(0))
    if (c == 1) "GRANNY" else if (c == 2) "GRANDPA" else if (c == 3) "BOTH" else "PLAYER"
  }

  def pickupTaken(flag: String): Boolean = flag match {
    case "FUEL" => hasFuel
    case "BATTERY" => hasBattery
    case "ROPE" => hasRope
    case "BOATKEY" => hasBoatKey
    case "WRENCH" => hasWrench
    case "CODE" => hasCode
    case _ => true
  }

  def takePickup(flag: String): Unit = flag match {
    case "FUEL" => hasFuel = true
    case "BATTERY" => hasBattery = true
    case "ROPE" => hasRope = true
    case "BOATKEY" => hasBoatKey = true
    case "WRENCH" => hasWrench = true
    case "CODE" => hasCode = true
    case _ => ()
  }

  def carReady: Boolean = hasKey && hasFuel && hasBattery && hasCode
  def boatReady: Boolean = hasBoatKey && hasFuel && hasBattery && hasRope && hasWrench

  def receiveNetworkState(line: String): Unit = {
    try {
      val a = line.split("\\|", -1)
      if (a.length >= 16) {
        remoteX = a(0).toDouble
        remoteZ = a(1).toDouble
        remoteYaw = a(2).toDouble
        remoteUpper = a(3) == "1"
        remoteRole = a(4)
        remoteActive = a(5) == "1"
        if (a.length >= 23) remoteHidden = a(22) == "1"
        if (a(13) == "1") { carEscaped = true; escaped = true; selectedEscape = "CAR" }
        if (a(14) == "1") { boatEscaped = true; escaped = true; selectedEscape = "BOAT" }
        if (a(15) == "1" && selectedEscape.isEmpty) escaped = true
        if (gameMode == "CLIENT" && a.length >= 22) {
          val hostDay = a(21).toInt
          if (hostDay > day) {
            day = hostDay
            px = 4.8; pz = 4.15; yaw = -math.Pi/2.0; pitch = 0.0; onUpperFloor = false
            hasKey = false; hasFuel = false; hasBattery = false; hasRope = false
            hasBoatKey = false; hasWrench = false; hasCode = false
            carEscaped = false; boatEscaped = false; escaped = false; selectedEscape = ""
            secretUnlocked = false; secretLootTaken = false
            doors.foreach(d => { d.angle = 0.0; d.target = 0.0 })
            say("DAY " + day + " - YOUR TEAM RESTARTED THE ESCAPE!", 4.0)
          }
          enemyX = a(17).toDouble
          enemyZ = a(18).toDouble
          enemyUpper = a(19) == "1"
          enemyStunnedTimer = a(20).toDouble
        } else if (gameMode == "HOST" && a.length >= 22) {
          val clientStun = a(20).toDouble
          if (clientStun > enemyStunnedTimer) enemyStunnedTimer = clientStun
        }
        hasKey = hasKey || a(6) == "1"
        hasFuel = hasFuel || a(7) == "1"
        hasBattery = hasBattery || a(8) == "1"
        hasRope = hasRope || a(9) == "1"
        hasBoatKey = hasBoatKey || a(10) == "1"
        hasWrench = hasWrench || a(11) == "1"
        hasCode = hasCode || a(12) == "1"
        if (a.length >= 17 && a(16).nonEmpty) {
          val angles = a(16).split(",")
          for (i <- 0 until math.min(doors.length, angles.length)) {
            val v = angles(i).toDouble
            if (v > doors(i).angle) { doors(i).angle = v; doors(i).target = v }
          }
        }
      }
    } catch { case _: Throwable => () }
  }

  def sendNetworkState(): Unit = {
    if (!LANLink.connected) return
    val upper = if (onUpperFloor) "1" else "0"
    val active = if (started && !gameOver) "1" else "0"
    def bit(v: Boolean) = if (v) "1" else "0"
    val angles = doors.map(_.angle.toString).mkString(",")
    val packet = Seq(px.toString, pz.toString, yaw.toString, upper, playerRole,
      active, bit(hasKey), bit(hasFuel), bit(hasBattery), bit(hasRope),
      bit(hasBoatKey), bit(hasWrench), bit(hasCode), bit(carEscaped),
      bit(boatEscaped), bit(escaped), angles, enemyX.toString, enemyZ.toString,
      bit(enemyUpper), enemyStunnedTimer.toString, day.toString, bit(hidden)).mkString("|")
    LANLink.send(packet)
  }

  // -------------------------- Geometry --------------------------

  def addBox(x: Double, y: Double, z: Double,
             sx: Double, sy: Double, sz: Double,
             color: Color, solid: Boolean = true): Unit = {
    val b = Box(x, y, z, sx, sy, sz, color, solid)
    triangles ++= boxTriangles(b)
    if (solid) solids += b
  }

  def boxTriangles(b: Box): ArrayBuffer[Tri] = {
    val x0 = b.x - b.sx / 2; val x1 = b.x + b.sx / 2
    val y0 = b.y - b.sy / 2; val y1 = b.y + b.sy / 2
    val z0 = b.z - b.sz / 2; val z1 = b.z + b.sz / 2
    val v = Array(
      V3(x0,y0,z0), V3(x1,y0,z0), V3(x1,y1,z0), V3(x0,y1,z0),
      V3(x0,y0,z1), V3(x1,y0,z1), V3(x1,y1,z1), V3(x0,y1,z1)
    )
    val faces = Array(
      Array(0,3,2,1), Array(4,5,6,7), Array(0,4,7,3),
      Array(1,2,6,5), Array(3,7,6,2), Array(0,1,5,4)
    )
    val factors = Array(0.68, 0.95, 0.58, 0.78, 1.22, 0.40)
    val out = ArrayBuffer[Tri]()
    for (i <- faces.indices) {
      val f = faces(i)
      val c = shade(b.color, factors(i))
      out += Tri(v(f(0)), v(f(1)), v(f(2)), c)
      out += Tri(v(f(0)), v(f(2)), v(f(3)), c)
    }
    out
  }

  def addWallX(x: Double, z0: Double, z1: Double,
               gaps: Seq[(Double, Double)], yBase: Double = 0.0,
               height: Double = 2.8, color: Color = WALL): Unit = {
    var cursor = z0
    for ((a,b) <- gaps.sortBy(_._1)) {
      if (a > cursor + 0.001)
        addBox(x, yBase + height/2, (cursor+a)/2,
          0.22, height, a-cursor, color, true)
      cursor = math.max(cursor,b)
    }
    if (cursor < z1 - 0.001)
      addBox(x, yBase + height/2, (cursor+z1)/2,
        0.22, height, z1-cursor, color, true)
  }

  def addWallZ(z: Double, x0: Double, x1: Double,
               gaps: Seq[(Double, Double)], yBase: Double = 0.0,
               height: Double = 2.8, color: Color = WALL): Unit = {
    var cursor = x0
    for ((a,b) <- gaps.sortBy(_._1)) {
      if (a > cursor + 0.001)
        addBox((cursor+a)/2, yBase + height/2, z,
          a-cursor, height, 0.22, color, true)
      cursor = math.max(cursor,b)
    }
    if (cursor < x1 - 0.001)
      addBox((cursor+x1)/2, yBase + height/2, z,
        x1-cursor, height, 0.22, color, true)
  }

  def addDoor(x: Double, z: Double, width: Double, plane: String,
              hingeSide: Double = 1.0, yBase: Double = 0.0,
              height: Double = 2.65, locked: Boolean = false,
              isExit: Boolean = false, name: String = "Door"): Unit = {
    doors += new Door(x,z,width,plane,hingeSide,yBase,height,locked,isExit,name)
  }

  def makeDoorTriangles(d: Door): ArrayBuffer[Tri] = {
    val c = math.cos(d.angle)
    val s = math.sin(d.angle)
    // Local width and normal before rotating around the hinge.
    val (baseUx, baseUz, baseNx, baseNz, pivotX, pivotZ) =
      if (d.plane == "X") {
        (0.0, d.hingeSide, d.hingeSide, 0.0,
         d.x, d.z - d.hingeSide*d.width/2)
      } else {
        (d.hingeSide, 0.0, 0.0, 1.0,
         d.x - d.hingeSide*d.width/2, d.z)
      }
    def rotateX(ux: Double, uz: Double): Double = ux*c + uz*s
    def rotateZ(ux: Double, uz: Double): Double = uz*c - ux*s
    val ux = rotateX(baseUx, baseUz)
    val uz = rotateZ(baseUx, baseUz)
    val nx = rotateX(baseNx, baseNz)
    val nz = rotateZ(baseNx, baseNz)
    val thick = 0.12
    def point(u: Double, v: Double, y: Double): V3 =
      V3(pivotX + ux*u + nx*v, y, pivotZ + uz*u + nz*v)
    val v = Array(
      point(0,-thick/2,d.yBase), point(d.width,-thick/2,d.yBase),
      point(d.width,-thick/2,d.yBase+d.height), point(0,-thick/2,d.yBase+d.height),
      point(0,thick/2,d.yBase), point(d.width,thick/2,d.yBase),
      point(d.width,thick/2,d.yBase+d.height), point(0,thick/2,d.yBase+d.height)
    )
    val faces = Array(
      Array(0,3,2,1), Array(4,5,6,7), Array(0,4,7,3),
      Array(1,2,6,5), Array(3,7,6,2), Array(0,1,5,4)
    )
    val out = ArrayBuffer[Tri]()
    val factors = Array(0.72, 0.88, 0.56, 0.92, 1.12, 0.42)
    for (i <- faces.indices) {
      val f = faces(i)
      val col = shade(if (d.isExit) new Color(75,40,27) else new Color(94,59,37), factors(i))
      out += Tri(v(f(0)),v(f(1)),v(f(2)),col)
      out += Tri(v(f(0)),v(f(2)),v(f(3)),col)
    }
    // Small handle on the front side.
    val handle = Box(
      pivotX + ux*d.width*0.82 + nx*0.09,
      d.yBase + d.height*0.48,
      pivotZ + uz*d.width*0.82 + nz*0.09,
      0.08, 0.08, 0.08, new Color(211,174,73), false
    )
    out ++= boxTriangles(handle)
    out
  }

  def buildHouse(): Unit = {
    triangles.clear(); solids.clear(); doors.clear()

    // Main ground-floor slab.
    addBox(12.0,-0.16,9.0,24.0,0.32,18.0,FLOOR,false)

    // Exterior walls, with a front exit opening.
    addBox(0.12,1.4,9.0,0.24,2.8,18.0,WALL,true)
    // Side passage opens from the house into the outdoor escape yard.
    addWallX(23.88,0.12,17.88,Seq((7.5,9.5)),0.0,2.8,WALL)
    addDoor(23.88,8.5,2.0,"X",1.0,0.0,2.65,false,false,"Yard Exit")
    addWallZ(0.12,0.12,23.88,Seq(),0.0,2.8,WALL_ALT)
    addWallZ(17.88,0.12,23.88,Seq((19.7,21.3)),0.0,2.8,WALL_ALT)

    // Ground-floor partitions. Door openings are real gaps in the wall.
    addWallX(6.0,0.25,17.75,Seq((2.4,3.6),(8.2,9.4)))
    addWallX(12.0,0.25,17.75,Seq((3.0,4.2),(8.0,9.2),(14.6,15.8)))
    // The broad opening to the right leads into the bathroom wing.
    addWallZ(6.0,0.25,23.75,Seq((3.5,4.8),(8.0,9.2),(14.3,17.3),(18.0,19.3)))
    // The x=4.0..5.2 gap is hidden behind a hinged secret panel.
    addWallZ(12.0,0.25,23.75,Seq((4.0,5.2),(8.5,9.7),(15.5,16.7),(19.9,21.3)))

    addDoor(6.0,3.0,1.2,"X",1.0,0.0,2.65,false,false,"Bedroom door")
    addDoor(6.0,8.8,1.2,"X",-1.0,0.0,2.65,false,false,"Hall door")
    addDoor(12.0,3.6,1.2,"X",-1.0,0.0,2.65,false,false,"Kitchen door")
    addDoor(12.0,8.6,1.2,"X",1.0,0.0,2.65,false,false,"Stair hall door")
    addDoor(12.0,15.2,1.2,"X",-1.0,0.0,2.65,false,false,"Back room door")
    addDoor(4.15,6.0,1.3,"Z",1.0,0.0,2.65,false,false,"Room door")
    addDoor(8.6,6.0,1.2,"Z",-1.0,0.0,2.65,false,false,"Hall door")
    addDoor(9.1,12.0,1.2,"Z",-1.0,0.0,2.65,false,false,"Back hall door")
    addDoor(16.1,12.0,1.2,"Z",1.0,0.0,2.65,false,false,"Back room door")
    addDoor(20.6,12.0,1.2,"Z",-1.0,0.0,2.65,false,false,"Exit hall door")
    addDoor(20.5,17.88,1.6,"Z",1.0,0.0,2.65,true,true,"Locked exit")
    addDoor(bathroomDoorX,bathroomDoorZ,1.3,"Z",1.0,0.0,2.65,false,false,"Bathroom door")
    addDoor(4.6,12.0,1.2,"Z",1.0,0.0,2.65,false,false,"Hidden shelf panel")

    // Bedroom furniture.
    addBox(2.3,0.23,2.1,2.25,0.34,2.9,DARK_WOOD,true)
    addBox(2.3,0.48,2.1,2.05,0.18,2.68,new Color(115,106,94),true)
    addBox(2.3,0.60,1.02,1.08,0.12,0.48,new Color(194,184,166),true)
    for (x <- Seq(1.35,3.25); z <- Seq(0.85,3.25))
      addBox(x,0.13,z,0.17,0.26,0.17,DARK_WOOD,true)

    // Wardrobe.
    addBox(wardrobeX,1.08,wardrobeZ,0.92,2.16,0.86,new Color(61,41,30),true)
    addBox(1.73,1.08,wardrobeZ,0.08,2.03,0.82,new Color(83,55,39),true)

    // Tables, cupboards and other simple room props.
    addBox(4.75,0.42,2.3,0.82,0.82,0.82,WOOD,true)
    addBox(4.75,0.86,2.3,0.88,0.08,0.88,DARK_WOOD,true)
    addBox(9.0,0.65,5.0,1.65,0.14,1.2,WOOD,true)
    for (x <- Seq(8.35,9.65); z <- Seq(4.55,5.45))
      addBox(x,0.30,z,0.13,0.60,0.13,DARK_WOOD,true)
    addBox(15.0,0.90,2.0,1.0,1.8,0.65,DARK_WOOD,true)
    addBox(18.6,0.90,8.6,1.05,1.8,0.65,WOOD,true)
    addBox(5.15,0.72,16.45,0.62,1.44,0.58,DARK_WOOD,true)
    addBox(18.5,0.38,14.7,1.3,0.72,0.8,DARK_WOOD,true)

    // Discoverable shelf in the back hall. Interact with it to reveal the panel.
    addBox(clueShelfX,0.95,clueShelfZ,1.05,1.9,0.36,DARK_WOOD,true)
    addBox(clueShelfX,0.42,clueShelfZ,1.18,0.10,0.48,WOOD,false)
    addBox(clueShelfX,1.00,clueShelfZ,1.18,0.10,0.48,WOOD,false)
    addBox(clueShelfX,1.58,clueShelfZ,1.18,0.10,0.48,WOOD,false)
    addBox(clueShelfX,2.08,clueShelfZ,1.18,0.10,0.48,WOOD,false)

    // Secret storage room shelf, behind the panel.
    addBox(secretShelfX,1.02,secretShelfZ,1.10,2.04,0.40,DARK_WOOD,true)
    addBox(secretShelfX,0.42,secretShelfZ,1.22,0.10,0.48,WOOD,false)
    addBox(secretShelfX,1.00,secretShelfZ,1.22,0.10,0.48,WOOD,false)
    addBox(secretShelfX,1.58,secretShelfZ,1.22,0.10,0.48,WOOD,false)
    addBox(secretShelfX,2.08,secretShelfZ,1.22,0.10,0.48,WOOD,false)

    // Bathroom fixtures in their own room: bathtub, toilet, sink and mirror.
    addBox(21.85,0.30,4.70,2.35,0.55,1.05,new Color(147,145,132),true)
    addBox(21.85,0.58,4.70,2.02,0.12,0.78,new Color(185,183,171),false)
    addBox(20.05,0.27,2.45,0.72,0.42,0.82,new Color(185,181,168),true)
    addBox(20.05,0.56,2.58,0.55,0.22,0.48,new Color(205,202,189),false)
    addBox(22.85,0.48,1.55,0.80,0.95,0.60,new Color(105,103,96),true)
    addBox(22.85,1.02,1.55,0.90,0.12,0.68,new Color(186,183,171),false)
    addBox(22.15,1.95,0.28,1.00,0.82,0.08,new Color(42,57,61),false)

    // Decorative climbable ladder connecting the lower hall to the upper wing.
    addBox(ladderX-0.24,1.50,ladderZ,0.07,2.95,0.08,DARK_WOOD,false)
    addBox(ladderX+0.24,1.50,ladderZ,0.07,2.95,0.08,DARK_WOOD,false)
    for (i <- 0 until 10) {
      addBox(ladderX,0.18+i*0.29,ladderZ,0.52,0.065,0.08,WOOD,false)
    }

    // Stairs: 10 actual polygonal steps; the collision floor follows the ramp.
    for (i <- 0 until 10) {
      val h = (i+1)*0.30
      val zc = 6.5 + (i+0.5)*0.40
      addBox(15.8,h/2,zc,3.0,h,0.42,new Color(87,63,46),false)
    }
    // Solid side walls keep the player on the stairs.
    addBox(14.20,1.45,8.5,0.18,2.9,4.2,DARK_WOOD,true)
    addBox(17.40,1.45,8.5,0.18,2.9,4.2,DARK_WOOD,true)
    // Ground floor wall prevents walking through the underside of the upper wing.
    addWallZ(10.5,17.45,23.75,Seq(),0.0,2.8,WALL_ALT)

    // Upper floor platform/wing. Top is y = 3.0.
    addBox(19.0,2.90,14.05,9.4,0.20,7.10,new Color(71,56,43),false)
    // Upper-level walls.
    addWallX(14.30,10.55,17.62,Seq((13.35,14.65)),3.0,2.8,WALL_ALT)
    addWallX(23.75,10.50,17.65,Seq(),3.0,2.8,WALL_ALT)
    addWallZ(10.50,17.35,23.75,Seq(),3.0,2.8,WALL_ALT)
    addWallZ(17.62,14.30,23.75,Seq(),3.0,2.8,WALL_ALT)
    addWallX(18.50,10.55,17.60,Seq((12.75,14.05)),3.0,2.8,WALL)
    addWallZ(14.00,18.50,23.70,Seq((20.0,21.3)),3.0,2.8,WALL)
    addDoor(14.30,14.0,1.3,"X",1.0,3.0,2.65,false,false,"Upper door")
    addDoor(18.50,13.4,1.3,"X",-1.0,3.0,2.65,false,false,"Upper hall door")
    addDoor(20.65,14.0,1.3,"Z",1.0,3.0,2.65,false,false,"Upper room door")

    // Upper-floor furniture at y=3.
    addBox(20.8,3.35,12.1,1.5,0.7,0.9,WOOD,true)
    addBox(22.1,3.55,15.5,1.0,1.1,0.7,DARK_WOOD,true)

    // ---------------- OUTDOOR ESCAPE YARD ----------------
    addBox(29.7,-0.16,16.0,11.6,0.32,32.0,new Color(52,61,49),false)
    addBox(35.45,1.35,16.0,0.22,2.7,32.0,new Color(52,48,39),true)
    addBox(29.7,1.35,0.18,11.6,2.7,0.22,new Color(52,48,39),true)
    addBox(29.7,1.35,31.82,11.6,2.7,0.22,new Color(52,48,39),true)

    // Garage with a hinged entry gate and a small car model.
    addWallX(27.25,2.2,8.0,Seq(),0.0,2.6,WALL_ALT)
    addWallX(33.1,2.2,8.0,Seq(),0.0,2.6,WALL_ALT)
    addWallZ(2.2,27.25,33.1,Seq(),0.0,2.6,WALL_ALT)
    addWallZ(8.0,27.25,33.1,Seq((29.1,31.2)),0.0,2.6,WALL_ALT)
    addDoor(30.15,8.0,2.1,"Z",1.0,0.0,2.5,false,false,"Garage Gate")
    addBox(30.1,0.45,5.0,3.1,0.62,1.65,new Color(35,75,105),false)
    addBox(30.1,0.95,5.0,1.65,0.55,1.35,new Color(45,91,118),false)
    addBox(29.45,0.28,4.35,0.52,0.35,0.18,new Color(20,20,22),false)
    addBox(30.75,0.28,4.35,0.52,0.35,0.18,new Color(20,20,22),false)
    addBox(29.45,0.28,5.65,0.52,0.35,0.18,new Color(20,20,22),false)
    addBox(30.75,0.28,5.65,0.52,0.35,0.18,new Color(20,20,22),false)

    // Reinforced underground-looking bunker room, with a hinged entrance.
    addWallX(26.5,13.0,20.0,Seq((15.7,17.1)),0.0,2.55,new Color(57,62,65))
    addWallX(33.0,13.0,20.0,Seq(),0.0,2.55,new Color(57,62,65))
    addWallZ(13.0,26.5,33.0,Seq(),0.0,2.55,new Color(57,62,65))
    addWallZ(20.0,26.5,33.0,Seq(),0.0,2.55,new Color(57,62,65))
    addDoor(26.5,16.4,1.4,"X",1.0,0.0,2.45,false,false,"Bunker Door")
    addBox(29.65,2.25,13.25,1.2,0.45,0.45,new Color(75,82,88),false)
    addBox(29.65,1.1,19.55,1.25,0.12,0.25,new Color(92,105,112),false)

    // Wooden dock, water and a small escape boat.
    addBox(29.8,-0.30,25.8,8.8,0.12,10.8,new Color(25,58,69),false)
    addBox(30.0,0.02,25.2,4.8,0.20,4.4,new Color(105,73,48),false)
    for (i <- 0 until 8) addBox(27.9+i*0.60,0.17,25.2,0.08,0.12,4.35,new Color(135,96,61),false)
    addBox(30.0,0.30,28.0,2.5,0.48,4.5,new Color(72,54,43),false)
    addBox(30.0,0.68,28.1,1.05,0.60,1.55,new Color(205,207,194),false)
    addBox(30.0,1.00,28.1,0.92,0.06,1.30,new Color(48,76,91),false)

    // Wall windows: dark glass plus pale frames.
    addBox(3.0,2.0,0.24,1.4,0.8,0.08,new Color(28,43,51),false)
    addBox(14.0,2.0,0.24,1.4,0.8,0.08,new Color(28,43,51),false)
    addBox(10.0,2.0,0.24,0.62,0.72,0.08,new Color(52,41,31),false)
  }

  // ------------------------ Floor & collision ------------------------

  def inStair(x: Double, z: Double): Boolean =
    x >= 14.30 && x <= 17.30 && z >= 6.50 && z <= 10.50

  def floorY(x: Double, z: Double, upper: Boolean): Double = {
    if (inStair(x,z)) 3.0 * clamp((z-6.50)/4.0,0.0,1.0)
    else if (upper) 3.0
    else 0.0
  }

  def updateLevel(upper: Boolean, x: Double, z: Double): Boolean = {
    if (inStair(x,z)) {
      if (z >= 10.46) true
      else if (z <= 6.54) false
      else upper
    } else upper
  }

  def boxBlocks(b: Box, x: Double, z: Double, upper: Boolean): Boolean = {
    val feet = floorY(x,z,upper)
    val bodyBottom = feet + 0.05
    val bodyTop = feet + 1.65
    val bBottom = b.y - b.sy/2
    val bTop = b.y + b.sy/2
    val vertical = bodyTop > bBottom && bodyBottom < bTop
    vertical && math.abs(x-b.x) < b.sx/2 + 0.19 &&
      math.abs(z-b.z) < b.sz/2 + 0.19
  }

  def doorBlocks(d: Door, x: Double, z: Double, upper: Boolean): Boolean = {
    val feet = floorY(x,z,upper)
    val overlapsY = feet + 1.65 > d.yBase && feet < d.yBase + d.height
    if (!overlapsY || d.angle > 0.92) false
    else if (d.plane == "X")
      math.abs(x-d.x) < 0.34 && math.abs(z-d.z) < d.width/2 + 0.20
    else
      math.abs(z-d.z) < 0.34 && math.abs(x-d.x) < d.width/2 + 0.20
  }

  def blocked(x: Double, z: Double, upper: Boolean): Boolean = {
    if (x < 0.35 || x > 35.20 || z < 0.35 || z > 31.55) return true
    if (z > 17.95 && x < 23.65) {
      val exitPassage = doors.exists(d => d.isExit && d.angle > 1.0 &&
        x > 19.35 && x < 21.65 && z < 19.1)
      if (!exitPassage) return true
    }
    solids.exists(b => boxBlocks(b,x,z,upper)) || doors.exists(d => doorBlocks(d,x,z,upper))
  }

  def movePlayer(dx: Double, dz: Double): Unit = {
    val nx = px + dx
    val levelX = updateLevel(onUpperFloor,nx,pz)
    if (!blocked(nx,pz,levelX)) { px = nx; onUpperFloor = levelX }

    val nz = pz + dz
    val levelZ = updateLevel(onUpperFloor,px,nz)
    if (!blocked(px,nz,levelZ)) { pz = nz; onUpperFloor = levelZ }
  }

  // ----------------------------- Gameplay -----------------------------

  def say(s: String, seconds: Double = 2.8): Unit = {
    message = s
    messageTimer = seconds
  }

  def nearestDoor(): Option[Door] = {
    val feet = floorY(px,pz,onUpperFloor)
    val nearby = doors.filter(d =>
      dist(px,pz,d.x,d.z) < 1.75 &&
      feet + 1.65 > d.yBase && feet < d.yBase + d.height
    )
    if (nearby.isEmpty) None else Some(nearby.minBy(d => dist(px,pz,d.x,d.z)))
  }

  def interact(): Unit = {
    if (!started || gameOver || escaped) return

    if (hidden) {
      hidden = false
      say("YOU LEFT " + (if (hidePlace.isEmpty) "YOUR HIDING PLACE" else hidePlace) + ".")
      hidePlace = ""
      return
    }

    // Collect nearby quest objects. Both LAN players share collected flags.
    val nearbyPickup = pickups.find(p => !pickupTaken(p.flag) && p.upper == onUpperFloor &&
      dist(px,pz,p.x,p.z) < 1.35)
    if (nearbyPickup.isDefined) {
      val p = nearbyPickup.get
      takePickup(p.flag)
      say(p.name + " COLLECTED! CHECK THE ESCAPE TASKS.", 3.8)
      return
    }

    if (!onUpperFloor && dist(px,pz,30.1,5.0) < 2.5) {
      if (carReady) {
        carEscaped = true; escaped = true; selectedEscape = "CAR"
        say("CAR ESCAPE COMPLETE! YOU GOT OUT OF THE OLD HOUSE!", 20.0)
        sendNetworkState()
      } else {
        say("CAR LOCKED. NEED: CAR KEY + FUEL + BATTERY + GARAGE CODE.", 4.5)
      }
      return
    }

    if (!onUpperFloor && dist(px,pz,30.0,28.0) < 2.8) {
      if (boatReady) {
        boatEscaped = true; escaped = true; selectedEscape = "BOAT"
        say("BOAT ESCAPE COMPLETE! YOU ESCAPED BY SEA!", 20.0)
        sendNetworkState()
      } else {
        say("BOAT NEEDS: BOAT KEY + FUEL + BATTERY + ROPE + WRENCH.", 4.5)
      }
      return
    }

    if (dist(px,pz,wardrobeX,wardrobeZ) < 1.25 && !onUpperFloor) {
      hidden = true
      hidePlace = "WARDROBE"
      say("HIDDEN IN THE WARDROBE. PRESS E TO LEAVE.")
      return
    }

    if (dist(px,pz,bedX,bedZ) < 1.65 && !onUpperFloor) {
      hidden = true
      hidePlace = "UNDER THE BED"
      say("HIDING UNDER THE BED. PRESS E TO CRAWL OUT.")
      return
    }

    if (dist(px,pz,ladderX,ladderZ) < 1.20) {
      onUpperFloor = !onUpperFloor
      say(if (onUpperFloor) "YOU CLIMBED THE LADDER UPSTAIRS." else "YOU CLIMBED THE LADDER DOWNSTAIRS.",3.0)
      return
    }

    if (dist(px,pz,clueShelfX,clueShelfZ) < 1.45 && !secretUnlocked && !onUpperFloor) {
      secretUnlocked = true
      doors.find(_.name == "Hidden shelf panel").foreach(_.target = 1.35)
      say("SECRET PANEL FOUND! A HIDDEN ROOM IS OPEN.",4.0)
      try { Toolkit.getDefaultToolkit.beep() } catch { case _: Throwable => () }
      return
    }

    if (secretUnlocked && dist(px,pz,secretShelfX,secretShelfZ) < 1.45 && !onUpperFloor) {
      if (!secretLootTaken) {
        secretLootTaken = true
        hasBomb = true
        say("SECRET SHELF: STUN BOMB COLLECTED. PRESS Q TO THROW IT.",4.0)
      } else {
        say("THE SECRET SHELF IS EMPTY.")
      }
      return
    }

    if (dist(px,pz,vaseX,vaseZ) < 1.25 && !vaseFalling && !vaseBroken && !onUpperFloor) {
      vaseFalling = true
      vaseVel = 0.0
      say("THE VASE IS FALLING...",1.5)
      try { Toolkit.getDefaultToolkit.beep() } catch { case _: Throwable => () }
      return
    }

    nearestDoor() match {
      case Some(d) =>
        if (d.name == "Hidden shelf panel" && !secretUnlocked) {
          say("THE PANEL HAS NO HANDLE. SEARCH THE SHELF NEARBY.")
        } else if (d.locked && !hasKey) {
          say("LOCKED. FIND THE KEY FIRST!")
        } else {
          d.target = if (d.target > 0.5) 0.0 else 1.35
          if (d.isExit && d.target > 0.5) say("EXIT DOOR OPENING...")
          else say(if (d.target > 0.5) d.name.toUpperCase + " OPENING" else d.name.toUpperCase + " CLOSING")
        }
      case None => say("NOTHING TO INTERACT WITH HERE.")
    }
  }

  def resetDay(): Unit = {
    day += 1
    if (day > 5) {
      gameOver = true
      say("DAY 5 FINISHED - GAME OVER",20.0)
    } else {
      if (gameMode == "CLIENT") { px = 4.8; pz = 4.15; yaw = -math.Pi/2.0 }
      else if (gameMode == "HOST") { px = 3.2; pz = 4.15; yaw = math.Pi/2.0 }
      else { px = 3.2; pz = 4.15; yaw = math.Pi }
      pitch = 0.0
      onUpperFloor = false; hidden = false; hidePlace = ""; hasKey = false
      hasFuel = false; hasBattery = false; hasRope = false; hasBoatKey = false
      hasWrench = false; hasCode = false; carEscaped = false; boatEscaped = false
      hasBomb = false; bombThrown = false; bombFuse = 0.0; bombBlastTimer = 0.0
      enemyStunnedTimer = 0.0; secretUnlocked = false; secretLootTaken = false
      doors.foreach(d => { d.angle = 0.0; d.target = 0.0 })
      enemyX = 9.4; enemyZ = 8.3; enemyUpper = false
      enemyTargetX = enemyX; enemyTargetZ = enemyZ
      vaseY = 0.9; vaseVel = 0.0; vaseFalling = false; vaseBroken = false
      noiseTimer = 0.0; wakeProgress = 0.0
      say("DAY " + day + " - YOU WOKE UP AGAIN!",4.0)
      started = true
    }
  }

  def resetGame(): Unit = {
    if (gameMode == "CLIENT") { px = 4.8; pz = 4.15; yaw = -math.Pi/2.0 }
    else if (gameMode == "HOST") { px = 3.2; pz = 4.15; yaw = math.Pi/2.0 }
    else { px = 3.2; pz = 4.15; yaw = math.Pi }
    pitch = 0.0
    if (playerRole == "GRANNY") enemyType = "GRANDPA"
    else if (playerRole == "GRANDPA") enemyType = "GRANNY"
    else if (playerRole == "BOTH") enemyType = "BOTH"
    onUpperFloor = false; wakeProgress = 0.0
    started = false; gameOver = false; escaped = false; hidden = false; hidePlace = ""
    hasKey = false; hasFuel = false; hasBattery = false; hasRope = false
    hasBoatKey = false; hasWrench = false; hasCode = false
    carEscaped = false; boatEscaped = false; selectedEscape = ""
    hasBomb = false; bombThrown = false; bombFuse = 0.0; bombBlastTimer = 0.0
    enemyStunnedTimer = 0.0; secretUnlocked = false; secretLootTaken = false
    flashlight = true; showMap = false; day = 1; netElapsed = 0.0
    remoteActive = false; remoteRole = "PLAYER"
    doors.foreach(d => { d.angle = 0.0; d.target = 0.0 })
    vaseY = 0.9; vaseVel = 0.0; vaseFalling = false; vaseBroken = false
    noiseTimer = 0.0; enemyX = 9.4; enemyZ = 8.3; enemyUpper = false
    enemyTargetX = enemyX; enemyTargetZ = enemyZ; aiTimer = 0.0; patrolIndex = 0
    message = "DAY 1 - PRESS ENTER TO WAKE UP"; messageTimer = 5.0
  }

  def wake(): Unit = {
    started = true
    wakeProgress = 0.0
    say("DAY 1. GET OUT OF THE BEDROOM.",4.0)
  }

  def playerEyeY: Double = floorY(px,pz,onUpperFloor) + 0.55 + 1.05*wakeProgress

  def updateDoors(dt: Double): Unit = {
    doors.foreach { d =>
      val speed = 1.9*dt
      if (d.angle < d.target) d.angle = math.min(d.target,d.angle+speed)
      else if (d.angle > d.target) d.angle = math.max(d.target,d.angle-speed)
    }
  }

  def walkableGrid(x: Double, z: Double, upper: Boolean): Boolean = !blocked(x,z,upper)

  def gridPathStep(sx: Double, sz: Double, tx: Double, tz: Double,
                   upper: Boolean): (Double,Double) = {
    val cell = 0.8
    val w = 45; val h = 40
    def gx(x: Double) = clamp((x/cell).toInt,0,w-1).toInt
    def gz(z: Double) = clamp((z/cell).toInt,0,h-1).toInt
    val start = gz(sz)*w + gx(sx)
    val goal = gz(tz)*w + gx(tx)
    if (start == goal) return (tx,tz)
    val prev = Array.fill(w*h)(-1)
    val q = mutable.Queue[Int]()
    prev(start) = start; q.enqueue(start)
    val ds = Array((1,0),(-1,0),(0,1),(0,-1))
    var checks = 0
    while (q.nonEmpty && prev(goal) == -1 && checks < w*h) {
      val cur = q.dequeue()
      val cx = cur%w; val cz = cur/w
      for ((dx,dz) <- ds) {
        val nx = cx+dx; val nz = cz+dz
        if (nx>=0 && nz>=0 && nx<w && nz<h) {
          val ni = nz*w+nx
          if (prev(ni) == -1 && walkableGrid(nx*cell+cell/2,nz*cell+cell/2,upper)) {
            prev(ni)=cur; q.enqueue(ni)
          }
        }
      }
      checks += 1
    }
    if (prev(goal) == -1) return (tx,tz)
    var cur = goal
    while (prev(cur) != start && cur != start && prev(cur) >= 0) cur = prev(cur)
    ((cur%w)*cell+cell/2,(cur/w)*cell+cell/2)
  }

  def throwStunBomb(): Unit = {
    if (!started || gameOver || escaped || hidden) return
    if (!hasBomb) {
      say("NO STUN BOMB. SEARCH THE SECRET SHELF.")
      return
    }
    if (bombThrown) {
      say("A STUN BOMB IS ALREADY IN THE AIR.")
      return
    }
    hasBomb = false
    bombThrown = true
    bombX = px + math.sin(yaw) * 0.55
    bombZ = pz + math.cos(yaw) * 0.55
    bombY = playerEyeY - 0.25
    bombDX = math.sin(yaw)
    bombDZ = math.cos(yaw)
    bombFuse = 0.85
    say("STUN BOMB THROWN! KEEP YOUR DISTANCE.",2.0)
  }

  def detonateStunBomb(): Unit = {
    bombThrown = false
    bombFuse = 0.0
    bombBlastTimer = 1.25
    noiseX = bombX
    noiseZ = bombZ
    noiseTimer = 7.0

    val hit = enemyUpper == onUpperFloor && dist(bombX,bombZ,enemyX,enemyZ) < 3.2
    if (hit) {
      enemyStunnedTimer = 7.0
      say("DIRECT HIT! THE PURSUER IS STUNNED.",3.0)
    } else {
      say("STUN BOMB BURST. THE SOUND DREW THE PURSUER.",3.0)
    }
    try { Toolkit.getDefaultToolkit.beep() } catch { case _: Throwable => () }
  }

  def updateEnemy(dt: Double): Unit = {
    val canTargetLocal = !hidden
    val canTargetRemote = remoteActive && !remoteHidden
    if ((!canTargetLocal && !canTargetRemote) || gameOver || escaped || enemyStunnedTimer > 0.0) return

    val localDistance = if (canTargetLocal) dist(px,pz,enemyX,enemyZ) else Double.MaxValue
    val remoteDistance = if (canTargetRemote) dist(remoteX,remoteZ,enemyX,enemyZ) else Double.MaxValue
    val targetRemote = remoteDistance < localDistance
    val dPlayer = math.min(localDistance,remoteDistance)
    val targetPlayerX = if (targetRemote) remoteX else px
    val targetPlayerZ = if (targetRemote) remoteZ else pz
    val targetPlayerUpper = if (targetRemote) remoteUpper else onUpperFloor
    var tx = enemyX; var tz = enemyZ
    var targetUpper = enemyUpper

    if (noiseTimer > 0.0) {
      // A ground-floor noise can draw the enemy down from the gallery.
      if (enemyUpper && !inStair(enemyX,enemyZ)) { tx = 15.8; tz = 10.30; targetUpper = true }
      else if (enemyUpper && inStair(enemyX,enemyZ)) { tx = 15.8; tz = 6.25; targetUpper = false }
      else { tx = noiseX; tz = noiseZ; targetUpper = false }
    } else if (dPlayer < 10.5) {
      // The host pursuer follows whichever visible teammate is closer.
      if (targetPlayerUpper && !enemyUpper) {
        if (enemyZ < 6.45 || enemyX < 14.3 || enemyX > 17.3) { tx = 15.8; tz = 6.70 }
        else { tx = 15.8; tz = 10.75 }
        targetUpper = false
      } else if (!targetPlayerUpper && enemyUpper) {
        if (enemyZ > 10.55 || enemyX < 14.3 || enemyX > 17.3) { tx = 15.8; tz = 10.30 }
        else { tx = 15.8; tz = 6.25 }
        targetUpper = true
      } else {
        tx = targetPlayerX; tz = targetPlayerZ; targetUpper = targetPlayerUpper
      }
    } else {
      val p = patrol(patrolIndex)
      tx = p._1; tz = p._2; targetUpper = false
      if (dist(enemyX,enemyZ,tx,tz)<0.8) patrolIndex = (patrolIndex+1)%patrol.length
    }

    // The pursuer can push open ordinary doors when it reaches them.
    doors.foreach { d =>
      if (!d.locked && !d.isExit &&
          (d.name != "Hidden shelf panel" || secretUnlocked) &&
          dist(enemyX,enemyZ,d.x,d.z) < 1.10) {
        d.target = 1.35
      }
    }

    aiTimer += dt
    if (aiTimer >= 0.55) {
      aiTimer = 0.0
      val step = gridPathStep(enemyX,enemyZ,tx,tz,enemyUpper)
      enemyTargetX = step._1; enemyTargetZ = step._2
    }

    val dx = enemyTargetX-enemyX; val dz = enemyTargetZ-enemyZ
    val len = math.sqrt(dx*dx+dz*dz)
    if (len > 0.06) {
      val step = math.min(len,0.95*dt)
      val nx = enemyX + dx/len*step
      val nz = enemyZ + dz/len*step
      val nxLevel = updateLevel(enemyUpper,nx,enemyZ)
      if (!blocked(nx,enemyZ,nxLevel)) { enemyX=nx; enemyUpper=nxLevel }
      val nzLevel = updateLevel(enemyUpper,enemyX,nz)
      if (!blocked(enemyX,nz,nzLevel)) { enemyZ=nz; enemyUpper=nzLevel }
    }

    val caughtLocal = !hidden && enemyUpper == onUpperFloor && dist(px,pz,enemyX,enemyZ)<0.62
    val caughtRemote = remoteActive && !remoteHidden && enemyUpper == remoteUpper &&
      dist(remoteX,remoteZ,enemyX,enemyZ)<0.62
    if (caughtLocal || caughtRemote) resetDay()
  }

  def updateGame(): Unit = {
    val dt = 0.033
    updateDoors(dt)
    if (!started || gameOver || escaped) return
    netElapsed += dt
    if (netElapsed >= 0.066) { netElapsed = 0.0; sendNetworkState() }
    if (messageTimer > 0.0) messageTimer -= dt
    if (noiseTimer > 0.0) noiseTimer -= dt
    if (enemyStunnedTimer > 0.0) enemyStunnedTimer = math.max(0.0,enemyStunnedTimer-dt)
    if (bombBlastTimer > 0.0) bombBlastTimer = math.max(0.0,bombBlastTimer-dt)
    wakeProgress = math.min(1.0,wakeProgress+dt*0.65)

    if (bombThrown) {
      val nx = bombX + bombDX * 5.2 * dt
      val nz = bombZ + bombDZ * 5.2 * dt
      if (blocked(nx,nz,onUpperFloor)) {
        bombFuse = math.min(bombFuse,0.01)
      } else {
        bombX = nx
        bombZ = nz
        bombY = floorY(bombX,bombZ,onUpperFloor) + 0.24 + 0.08*math.sin((0.85-bombFuse)*10.0)
      }
      bombFuse -= dt
      if (bombFuse <= 0.0) detonateStunBomb()
    }

    if (vaseFalling && !vaseBroken) {
      vaseVel += 9.8*dt
      vaseY -= vaseVel*dt
      if (vaseY <= 0.12) {
        vaseY=0.12; vaseBroken=true; vaseFalling=false
        noiseX=vaseX; noiseZ=vaseZ; noiseTimer=12.0
        say("CRASH! THE ENEMY HEARD THAT!",4.0)
      }
    }

    if (!hidden) {
      var forward=0.0; var side=0.0
      if (keys.contains(KeyEvent.VK_W)) forward += 1.0
      if (keys.contains(KeyEvent.VK_S)) forward -= 1.0
      if (keys.contains(KeyEvent.VK_D)) side += 1.0
      if (keys.contains(KeyEvent.VK_A)) side -= 1.0
      val len=math.sqrt(forward*forward+side*side)
      if (len>0.0) {
        val speed=if (keys.contains(KeyEvent.VK_SHIFT)) 3.3 else 2.0
        val f=forward/len*speed*dt; val s=side/len*speed*dt
        val dx=math.sin(yaw)*f+math.cos(yaw)*s
        val dz=math.cos(yaw)*f-math.sin(yaw)*s
        movePlayer(dx,dz)
      }
    }

    // Bumping the fragile vase also knocks it down; it need not be pressed with E.
    if (!vaseBroken && !vaseFalling && !onUpperFloor && dist(px,pz,vaseX,vaseZ) < 0.68) {
      vaseFalling = true
      vaseVel = 0.0
      say("YOU KNOCKED THE VASE OVER!",1.4)
    }

    if (!hasKey && !onUpperFloor && dist(px,pz,keyX,keyZ)<0.55) {
      hasKey=true; say("KEY COLLECTED. FIND THE EXIT!",3.5)
    }

    val exit = doors.find(_.isExit)
    if (exit.isDefined && exit.get.angle>1.0 && !onUpperFloor &&
        px>19.4 && px<21.6 && pz>18.0) {
      escaped=true; selectedEscape = "MAIN EXIT"; say("YOU ESCAPED THE OLD HOUSE!",20.0)
      sendNetworkState()
    }

    if (gameMode != "CLIENT") updateEnemy(dt)
  }

  // ---------------------- Camera & depth renderer ----------------------

  def camera(v: V3): CV = {
    val dx=v.x-px; val dy=v.y-playerEyeY; val dz=v.z-pz
    val cx=dx*math.cos(yaw)-dz*math.sin(yaw)
    val forward=dx*math.sin(yaw)+dz*math.cos(yaw)
    val cp=math.cos(pitch); val sp=math.sin(pitch)
    CV(cx,dy*cp-forward*sp,dy*sp+forward*cp)
  }

  def clipNear(poly0: Vector[CV]): Vector[CV] = {
    val out=ArrayBuffer[CV]()
    var i=0
    while (i<poly0.length) {
      val a=poly0(i); val b=poly0((i+1)%poly0.length)
      val ia=a.z>=NEAR; val ib=b.z>=NEAR
      if (ia && ib) out += b
      else if (ia && !ib) {
        val t=(NEAR-a.z)/(b.z-a.z)
        out += CV(a.x+(b.x-a.x)*t,a.y+(b.y-a.y)*t,NEAR)
      } else if (!ia && ib) {
        val t=(NEAR-a.z)/(b.z-a.z)
        out += CV(a.x+(b.x-a.x)*t,a.y+(b.y-a.y)*t,NEAR)
        out += b
      }
      i+=1
    }
    out.toVector
  }

  def edge(ax: Double, ay: Double, bx: Double, by: Double,
           px0: Double, py0: Double): Double =
    (px0-ax)*(by-ay)-(py0-ay)*(bx-ax)

  def rasterTriangle(a: CV,b: CV,c: CV,color: Color,w: Int,h: Int): Unit = {
    val ax=w/2.0+a.x*FOCAL/a.z; val ay=h/2.0-a.y*FOCAL/a.z
    val bx=w/2.0+b.x*FOCAL/b.z; val by=h/2.0-b.y*FOCAL/b.z
    val cx=w/2.0+c.x*FOCAL/c.z; val cy=h/2.0-c.y*FOCAL/c.z
    val area=edge(ax,ay,bx,by,cx,cy)
    if (math.abs(area)<0.00001) return

    val minX=math.max(0,math.floor(math.min(ax,math.min(bx,cx))).toInt)
    val maxX=math.min(w-1,math.ceil(math.max(ax,math.max(bx,cx))).toInt)
    val minY=math.max(0,math.floor(math.min(ay,math.min(by,cy))).toInt)
    val maxY=math.min(h-1,math.ceil(math.max(ay,math.max(by,cy))).toInt)
    if (minX>maxX || minY>maxY) return

    val rgb=color.getRGB
    val invA=1.0/a.z; val invB=1.0/b.z; val invC=1.0/c.z
    var y=minY
    while (y<=maxY) {
      var x=minX
      val py0=y+0.5
      while (x<=maxX) {
        val px0=x+0.5
        val wa=edge(bx,by,cx,cy,px0,py0)/area
        val wb=edge(cx,cy,ax,ay,px0,py0)/area
        val wc=1.0-wa-wb
        if (wa >= -0.00001 && wb >= -0.00001 && wc >= -0.00001) {
          val q=wa*invA+wb*invB+wc*invC
          val idx=y*w+x
          if (q>zBuffer(idx)) { zBuffer(idx)=q; pixels(idx)=rgb }
        }
        x+=1
      }
      y+=1
    }
  }

  def dynamicGeometry(): ArrayBuffer[Tri] = {
    val out=ArrayBuffer[Tri]()
    doors.foreach(d => out ++= makeDoorTriangles(d))

    // Key is a small low-poly gold object.
    if (!hasKey) {
      out ++= boxTriangles(Box(keyX,0.20,keyZ,0.42,0.06,0.06,new Color(236,190,42),false))
      out ++= boxTriangles(Box(keyX-0.22,0.20,keyZ,0.13,0.09,0.11,new Color(250,213,66),false))
    }

    // Thrown stun charge and brief blast flash.
    if (bombThrown) {
      out ++= boxTriangles(Box(bombX,bombY,bombZ,0.24,0.24,0.24,new Color(65,190,220),false))
      out ++= boxTriangles(Box(bombX,bombY+0.10,bombZ,0.10,0.10,0.10,new Color(240,220,120),false))
    }
    if (bombBlastTimer > 0.0) {
      val radius = 0.35 + (1.25-bombBlastTimer)*2.2
      out ++= boxTriangles(Box(bombX,bombY,bombZ,radius,0.10,radius,new Color(85,185,220),false))
    }

    // Vase and broken fragments.
    if (!vaseBroken) {
      out ++= boxTriangles(Box(vaseX,vaseY,vaseZ,0.24,0.34,0.24,new Color(118,64,48),false))
      out ++= boxTriangles(Box(vaseX,vaseY+0.17,vaseZ,0.16,0.05,0.16,new Color(155,100,68),false))
    } else {
      out ++= boxTriangles(Box(vaseX-0.12,0.06,vaseZ,0.18,0.05,0.12,new Color(118,64,48),false))
      out ++= boxTriangles(Box(vaseX+0.13,0.05,vaseZ+0.04,0.14,0.04,0.09,new Color(155,100,68),false))
    }

    // Low-poly enemy: robot appears when both LAN users select Survivor.
    val ey=if (enemyUpper) 3.0 else floorY(enemyX,enemyZ,false)
    val robotMode = (gameMode != "SINGLE" && playerRole == "PLAYER" &&
      remoteActive && remoteRole == "PLAYER") || (gameMode == "SINGLE" && enemyType == "ROBOT")
    val enemyBody = if (robotMode) new Color(78,116,128) else new Color(65,17,22)
    val enemyHead = if (robotMode) new Color(155,181,188) else new Color(174,151,127)
    out ++= boxTriangles(Box(enemyX,ey+1.02,enemyZ,0.50,1.12,0.36,enemyBody,false))
    out ++= boxTriangles(Box(enemyX,ey+1.78,enemyZ,0.42,0.43,0.42,enemyHead,false))
    out ++= boxTriangles(Box(enemyX,ey+1.99,enemyZ,0.47,0.14,0.47,new Color(28,22,20),false))
    out ++= boxTriangles(Box(enemyX-0.32,ey+1.04,enemyZ,0.17,0.82,0.19,enemyBody,false))
    out ++= boxTriangles(Box(enemyX+0.32,ey+1.04,enemyZ,0.17,0.82,0.19,enemyBody,false))
    out ++= boxTriangles(Box(enemyX-0.14,ey+0.31,enemyZ,0.19,0.60,0.22,new Color(35,28,27),false))
    out ++= boxTriangles(Box(enemyX+0.14,ey+0.31,enemyZ,0.19,0.60,0.22,new Color(35,28,27),false))

    // Quest pickups, rendered as bright small low-poly objects.
    pickups.filter(p => !pickupTaken(p.flag)).foreach { p =>
      val py = if (p.upper) 3.22 else 0.22
      out ++= boxTriangles(Box(p.x,py,p.z,0.32,0.34,0.32,p.color,false))
      out ++= boxTriangles(Box(p.x,py+0.20,p.z,0.18,0.08,0.18,shade(p.color,1.3),false))
    }

    // The remote LAN player is drawn in the same 3D world.
    if (remoteActive) {
      val avatarOffsets = if (remoteRole == "BOTH") Seq(-0.34, 0.34) else Seq(0.0)
      avatarOffsets.foreach { offset =>
        val ax = remoteX + offset
        val ry = if (remoteUpper) 3.0 else floorY(ax,remoteZ,false)
        val avatar = if (remoteRole == "GRANNY" || (remoteRole == "BOTH" && offset < 0.0)) new Color(145,76,145)
          else if (remoteRole == "GRANDPA" || remoteRole == "BOTH") new Color(75,103,154)
          else new Color(52,155,112)
        out ++= boxTriangles(Box(ax,ry+1.02,remoteZ,0.48,1.08,0.34,avatar,false))
        out ++= boxTriangles(Box(ax,ry+1.76,remoteZ,0.39,0.40,0.39,new Color(194,166,139),false))
        out ++= boxTriangles(Box(ax,ry+0.31,remoteZ-0.10,0.16,0.58,0.18,new Color(35,31,30),false))
        out ++= boxTriangles(Box(ax,ry+0.31,remoteZ+0.10,0.16,0.58,0.18,new Color(35,31,30),false))
      }
    }

    // BOTH mode shows a second pursuer alongside the first AI.
    if (enemyType == "BOTH" || playerRole == "BOTH" || remoteRole == "BOTH") {
      val ex2 = enemyX + 0.72
      out ++= boxTriangles(Box(ex2,ey+1.02,enemyZ+0.18,0.44,1.04,0.34,new Color(107,48,73),false))
      out ++= boxTriangles(Box(ex2,ey+1.76,enemyZ+0.18,0.38,0.39,0.38,new Color(183,158,139),false))
      out ++= boxTriangles(Box(ex2,ey+0.30,enemyZ+0.18,0.16,0.58,0.18,new Color(35,28,27),false))
    }
    out
  }

  def lightColor(t: Tri): Color = {
    val cx=(t.a.x+t.b.x+t.c.x)/3.0
    val cz=(t.a.z+t.b.z+t.c.z)/3.0
    val dx=cx-px; val dz=cz-pz
    val d=math.sqrt(dx*dx+dz*dz)
    val dot=if (d<0.001) 1.0 else (dx*math.sin(yaw)+dz*math.cos(yaw))/d
    val light=if (flashlight) 0.18+0.82*math.max(0.0,dot+0.12)/(1.0+d*0.025)
              else 0.16
    shade(t.color,light)
  }

  def renderWorld(w: Int,h: Int): Unit = {
    if (image==null || image.getWidth!=w || image.getHeight!=h) {
      image=new BufferedImage(w,h,BufferedImage.TYPE_INT_RGB)
      pixels=image.getRaster.getDataBuffer.asInstanceOf[DataBufferInt].getData
      zBuffer=new Array[Double](w*h)
    }
    val bg=image.createGraphics()
    bg.setPaint(new GradientPaint(0f,0f,new Color(9,11,15),0f,(h*0.5).toFloat,new Color(47,40,34)))
    bg.fillRect(0,0,w,h)
    bg.setPaint(new GradientPaint(0f,(h*0.5).toFloat,new Color(47,39,31),0f,h.toFloat,new Color(8,8,10)))
    bg.fillRect(0,h/2,w,h/2)
    bg.dispose()
    java.util.Arrays.fill(zBuffer,0.0)

    val dyn=dynamicGeometry()
    val all=triangles ++ dyn
    all.foreach { t =>
      val clipped=clipNear(Vector(camera(t.a),camera(t.b),camera(t.c)))
      if (clipped.length>=3) {
        val col=lightColor(t)
        var i=1
        while (i<clipped.length-1) {
          rasterTriangle(clipped(0),clipped(i),clipped(i+1),col,w,h)
          i+=1
        }
      }
    }
  }

  def drawMap(g: Graphics2D,w: Int): Unit = {
    // Scaled to include the house, outdoor yard, garage, bunker and dock.
    val ox=w-130; val oy=145; val scale=3.1
    g.setColor(new Color(0,0,0,190)); g.fillRect(ox-6,oy-6,130,110)
    g.setColor(new Color(125,112,96))
    solids.foreach { b =>
      if (b.y < 2.0) g.fillRect(ox+((b.x-b.sx/2)*scale).toInt,
        oy+((b.z-b.sz/2)*scale).toInt,math.max(1,(b.sx*scale).toInt),math.max(1,(b.sz*scale).toInt))
    }
    pickups.filter(p => !pickupTaken(p.flag)).foreach { p =>
      g.setColor(p.color)
      g.fillOval(ox+(p.x*scale).toInt-2,oy+(p.z*scale).toInt-2,5,5)
    }
    g.setColor(Color.RED); g.fillOval(ox+(enemyX*scale).toInt-3,oy+(enemyZ*scale).toInt-3,7,7)
    if (remoteActive) {
      g.setColor(new Color(90,220,120))
      g.fillOval(ox+(remoteX*scale).toInt-3,oy+(remoteZ*scale).toInt-3,7,7)
    }
    g.setColor(Color.CYAN); g.fillOval(ox+(px*scale).toInt-3,oy+(pz*scale).toInt-3,7,7)
  }

  def drawHUD(g: Graphics2D,w: Int,h: Int): Unit = {
    g.setColor(new Color(0,0,0,190)); g.fillRect(0,0,w,125)
    g.setFont(new Font("Arial",Font.BOLD,16)); g.setColor(new Color(245,222,184))
    g.drawString("DAY "+day+"   ROLE: "+playerRole+"   KEY: "+(if(hasKey) "YES" else "NO")+
      "   FLOOR: "+(if(onUpperFloor) "UPSTAIRS" else "GROUND")+
      "   SECRET: "+(if(secretUnlocked) "OPEN" else "HIDDEN"),14,23)
    g.setFont(new Font("Arial",Font.PLAIN,13)); g.setColor(Color.WHITE)
    g.drawString("WASD Move | Mouse drag Look | SHIFT Run | E Interact | Q Stun | F Light | M Map | R Restart",14,45)
    g.setColor(new Color(245,222,184))
    val connection = if (gameMode == "SINGLE") "SINGLE PLAYER" else if (LANLink.connected) "2 PLAYERS CONNECTED" else "WAITING FOR PLAYER 2"
    g.drawString("TASKS  KEY:"+(if(hasKey) "OK" else "--")+
      " FUEL:"+(if(hasFuel) "OK" else "--")+" BATTERY:"+(if(hasBattery) "OK" else "--")+
      " ROPE:"+(if(hasRope) "OK" else "--")+" BOAT KEY:"+(if(hasBoatKey) "OK" else "--")+
      " WRENCH:"+(if(hasWrench) "OK" else "--")+" CODE:"+(if(hasCode) "OK" else "--"),14,76)
    g.drawString("CAR "+(if(carReady) "READY" else "LOCKED")+" | BOAT "+(if(boatReady) "READY" else "LOCKED")+
      " | "+connection+(if(remoteActive) " | PARTNER: "+remoteRole else ""),14,96)
    if (hidden) g.drawString("HIDING: "+hidePlace,14,116)
    if (enemyStunnedTimer>0.0) g.drawString("PURSUER STUNNED",250,116)
    g.setColor(new Color(0,0,0,180)); g.fillRect(0,h-39,w,39)
    if (messageTimer>0.0) {
      g.setFont(new Font("Arial",Font.BOLD,16)); g.setColor(new Color(255,225,170))
      g.drawString(message,14,h-14)
    }
    if (!hidden && enemyUpper==onUpperFloor && dist(px,pz,enemyX,enemyZ)<5.0) {
      g.setFont(new Font("Arial",Font.BOLD,15)); g.setColor(new Color(255,70,60))
      val warning = if ((gameMode != "SINGLE" && playerRole == "PLAYER" && remoteActive && remoteRole == "PLAYER") || enemyType == "ROBOT") "ROBOT IS NEAR..." else "SOMETHING IS NEAR..."
      g.drawString(warning,w-235,82)
    }
    if (gameOver || escaped) {
      g.setColor(new Color(0,0,0,220)); g.fillRect(0,0,w,h)
      g.setFont(new Font("Arial",Font.BOLD,42)); g.setColor(if(escaped) new Color(90,255,125) else new Color(255,75,60))
      val title=if(escaped) "YOU ESCAPED!" else "GAME OVER"
      g.drawString(title,(w-g.getFontMetrics.stringWidth(title))/2,h/2)
      g.setFont(new Font("Arial",Font.BOLD,20)); g.setColor(Color.WHITE)
      val route = if (selectedEscape.nonEmpty) "ESCAPE ROUTE: " + selectedEscape else ""
      g.drawString(route,w/2-120,h/2+16)
      g.drawString("Press R to restart",w/2-95,h/2+54)
    }
  }

  def drawIntro(g: Graphics2D,w: Int,h: Int): Unit = {
    g.setPaint(new GradientPaint(0f,0f,new Color(5,6,9),w.toFloat,h.toFloat,new Color(59,23,20)))
    g.fillRect(0,0,w,h)
    g.setColor(new Color(25,20,18)); g.fillRect(80,85,w-160,h-155)
    g.setColor(new Color(65,45,33)); g.fillRect(130,120,w-260,h-225)
    g.setColor(new Color(32,24,22)); g.fillRect(160,h-170,260,55)
    g.setColor(new Color(100,80,65)); g.fillRect(180,h-195,220,32)
    g.setColor(new Color(40,24,18)); g.fillRect(w-270,150,90,250)
    g.setColor(new Color(210,165,68)); g.fillOval(w-211,275,11,11)
    g.setFont(new Font("Arial",Font.BOLD,45)); g.setColor(new Color(222,211,194))
    val title="OLD HOUSE 3D - ULTIMATE"; g.drawString(title,(w-g.getFontMetrics.stringWidth(title))/2,70)
    g.setFont(new Font("Arial",Font.BOLD,21)); g.setColor(new Color(255,95,75))
    val d="DAY 1"; g.drawString(d,(w-g.getFontMetrics.stringWidth(d))/2,h-105)
    g.setFont(new Font("Arial",Font.BOLD,20)); g.setColor(new Color(250,220,160))
    val p="PRESS ENTER TO START YOUR ESCAPE"; g.drawString(p,(w-g.getFontMetrics.stringWidth(p))/2,h-62)
    g.setFont(new Font("Arial",Font.PLAIN,15)); g.setColor(new Color(220,210,195))
    val hint="Find the key, fuel, battery, rope and tools. Escape by car or boat."
    g.drawString(hint,(w-g.getFontMetrics.stringWidth(hint))/2,h-28)
  }

  class GamePanel extends JPanel {
    setPreferredSize(new Dimension(SW,SH)); setFocusable(true); setBackground(Color.BLACK)
    setFocusTraversalKeysEnabled(false)
    override def paintComponent(graphics: Graphics): Unit = {
      super.paintComponent(graphics)
      val g=graphics.create().asInstanceOf[Graphics2D]
      g.setRenderingHint(RenderingHints.KEY_ANTIALIASING,RenderingHints.VALUE_ANTIALIAS_ON)
      if (!started) drawIntro(g,getWidth,getHeight)
      else {
        renderWorld(getWidth,getHeight)
        g.drawImage(image,0,0,null)
        g.setColor(new Color(230,230,215,170))
        g.drawLine(getWidth/2-5,getHeight/2,getWidth/2+5,getHeight/2)
        g.drawLine(getWidth/2,getHeight/2-5,getWidth/2,getHeight/2+5)
        drawHUD(g,getWidth,getHeight)
        if (showMap) drawMap(g,getWidth)
      }
      g.dispose()
    }
  }

  def start(): Unit = {
    configureGame()
    buildHouse(); resetGame()
    SwingUtilities.invokeLater(new Runnable {
      def run(): Unit = {
        frame=new JFrame("OLD HOUSE 3D - ULTIMATE MULTIPLAYER ESCAPE")
        panel=new GamePanel()
        panel.addKeyListener(new KeyAdapter {
          override def keyPressed(e: KeyEvent): Unit = {
            val k=e.getKeyCode; keys.add(k)
            if (k==KeyEvent.VK_ENTER && !started && !gameOver) wake()
            if (k==KeyEvent.VK_E) interact()
            if (k==KeyEvent.VK_Q) throwStunBomb()
            if (k==KeyEvent.VK_F) { flashlight = !flashlight; say(if(flashlight) "FLASHLIGHT ON" else "FLASHLIGHT OFF") }
            if (k==KeyEvent.VK_M) showMap = !showMap
            if (k==KeyEvent.VK_R) resetGame()
            if (k==KeyEvent.VK_LEFT) yaw -= 0.08
            if (k==KeyEvent.VK_RIGHT) yaw += 0.08
          }
          override def keyReleased(e: KeyEvent): Unit = keys.remove(e.getKeyCode)
        })
        panel.addMouseListener(new MouseAdapter {
          override def mousePressed(e: MouseEvent): Unit = { lastMouseX=e.getX; lastMouseY=e.getY; panel.requestFocusInWindow() }
          override def mouseReleased(e: MouseEvent): Unit = { lastMouseX = -1; lastMouseY = -1 }
        })
        panel.addMouseMotionListener(new MouseMotionAdapter {
          override def mouseDragged(e: MouseEvent): Unit = {
            if (started && !gameOver && !escaped && lastMouseX>=0) {
              val dx=e.getX-lastMouseX; val dy=e.getY-lastMouseY
              yaw += dx*0.007; pitch=clamp(pitch-dy*0.004,-0.55,0.55)
              lastMouseX=e.getX; lastMouseY=e.getY
            }
          }
        })
        panel.addFocusListener(new java.awt.event.FocusAdapter {
          override def focusLost(e: java.awt.event.FocusEvent): Unit = keys.clear()
        })
        frame.setContentPane(panel); frame.setDefaultCloseOperation(WindowConstants.DISPOSE_ON_CLOSE)
        frame.addWindowListener(new WindowAdapter {
          override def windowClosing(e: WindowEvent): Unit = {
            try { if (gameTimer != null) gameTimer.stop() } catch { case _: Throwable => () }
            LANLink.close()
          }
        })
        frame.pack(); frame.setLocationRelativeTo(null); frame.setVisible(true); panel.requestFocusInWindow()
        gameTimer=new Timer(33,new ActionListener { def actionPerformed(e: ActionEvent): Unit = { updateGame(); panel.repaint() } })
        gameTimer.start()
      }
    })
  }
}

OldHouse3DImproved.start()