///|
priv struct Camera {
  mut position : Vec3
  mut yaw : Double
  mut pitch : Double
  mut vel_y : Double
}

///|
priv struct Game {
  renderer : Renderer
  world : World
  chunk_size : Int
  view_distance : Int
  mut current_cx : Int
  mut current_cz : Int
  mut visible_index : @hashmap.HashMap[String, Int]
  mut slot_coords : Array[(Int, Int)]
  mut dirty_chunks : Array[(Int, Int)]
  mut chunk_queue_surface : Array[(Int, Int)]
  mut chunk_queue_surface_cursor : Int
  mut queued_surface : @hashmap.HashMap[String, Bool]
  mut chunk_queue_detail : Array[(Int, Int)]
  mut chunk_queue_detail_cursor : Int
  mut queued_detail : @hashmap.HashMap[String, Bool]
  chunk_build_budget : Int
  chunk_time_budget_ms : Double
  mut initial_loading : Bool
  mut initial_total : Int
  mut initial_built : Int
  detail_distance : Int
  camera : Camera
  mut player_height : Double
  mut last_time : Double
  mut last_w_down : Bool
  mut last_w_tap : Double
  mut sprint_latch : Bool
  mut selected_block : Block
  mut selected_slot : Int
  mut slot_detail : Array[Bool]
  mut particles : Array[Particle]
  mut rng_state : Int
}

///|
priv struct Particle {
  position : Vec3
  velocity : Vec3
  life : Double
  size : Double
  color : Vec3
  tile_x : Int
  tile_y : Int
}

///|
fn find_spawn(world : World) -> Vec3 {
  let sea = world.sea_level
  let random_radius = 4096.0
  let tries = 4000
  for i = 0; i < tries; i = i + 1 {
    let rx = (hash2(i * 37 + 11, i * 17 - 5) * 2.0 - 1.0) * random_radius
    let rz = (hash2(i * 29 - 23, i * 41 + 9) * 2.0 - 1.0) * random_radius
    let x = rx.floor().to_int()
    let z = rz.floor().to_int()
    let h = world.height_at(x, z)
    if h > sea + 1 {
      let y = h.to_double() + 2.0
      return vec3(x.to_double(), y, z.to_double())
    }
  }
  let max_radius = 256
  let mut best_x = 0
  let mut best_z = 0
  let mut best_h = -9999
  for r = 0; r <= max_radius; r = r + 1 {
    let start = -r
    let end = r
    for z = start; z <= end; z = z + 1 {
      for x = start; x <= end; x = x + 1 {
        let h = world.height_at(x, z)
        if h > best_h {
          best_h = h
          best_x = x
          best_z = z
        }
        if h > sea + 1 {
          let y = h.to_double() + 2.0
          return vec3(x.to_double(), y, z.to_double())
        }
      }
    }
  }
  let safe_h = if best_h > sea + 1 { best_h } else { sea + 2 }
  vec3(best_x.to_double(), safe_h.to_double() + 2.0, best_z.to_double())
}

///|
fn random_spawn(game : Game) -> Vec3 {
  let sea = game.world.sea_level
  let radius = 768.0
  let tries = 200
  for i = 0; i < tries; i = i + 1 {
    let rx = (rand_next(game) * 2.0 - 1.0) * radius
    let rz = (rand_next(game) * 2.0 - 1.0) * radius
    let x = rx.floor().to_int()
    let z = rz.floor().to_int()
    let h = game.world.height_at(x, z)
    if h > sea + 1 {
      let pos = vec3(
        x.to_double() + 0.5,
        h.to_double() + 2.0,
        z.to_double() + 0.5,
      )
      if position_free(game.world, pos, standing_height()) {
        return pos
      }
    }
  }
  find_spawn(game.world)
}

///|
fn init_game() -> Unit {
  let canvas = dom_create_canvas()
  input_init(canvas)
  dom_init_fly_toggle()
  dom_init_random_teleport()
  dom_init_command_input()
  let renderer = renderer_init(canvas)
  let world = World::new(2000, 16, 32)
  let chunk_size = 16
  let view_distance = 20
  let detail_distance = if view_distance > 6 {
    view_distance / 2
  } else {
    view_distance
  }
  let visible_capacity = (2 * view_distance + 1) * (2 * view_distance + 1) * 2 +
    1
  let spawn_pos = find_spawn(world)
  let spawn_x = spawn_pos.x.floor().to_int()
  let spawn_z = spawn_pos.z.floor().to_int()
  let camera = { position: spawn_pos, yaw: -1.2, pitch: -0.4, vel_y: 0.0 }
  let current_cx = div_floor(spawn_x, chunk_size)
  let current_cz = div_floor(spawn_z, chunk_size)
  let game = {
    renderer,
    world,
    chunk_size,
    view_distance,
    current_cx,
    current_cz,
    visible_index: @hashmap.new(capacity=visible_capacity),
    slot_coords: [],
    dirty_chunks: [],
    chunk_queue_surface: [],
    chunk_queue_surface_cursor: 0,
    queued_surface: @hashmap.new(capacity=visible_capacity),
    chunk_queue_detail: [],
    chunk_queue_detail_cursor: 0,
    queued_detail: @hashmap.new(capacity=visible_capacity),
    chunk_build_budget: 2,
    chunk_time_budget_ms: 5.0,
    initial_loading: true,
    initial_total: 0,
    initial_built: 0,
    detail_distance,
    camera,
    player_height: standing_height(),
    last_time: dom_now_ms(),
    last_w_down: false,
    last_w_tap: 0.0,
    sprint_latch: false,
    selected_block: Block::Dirt,
    selected_slot: 1,
    slot_detail: [],
    particles: [],
    rng_state: 1337,
  }
  refresh_visible_chunks(game)
  dom_set_hotbar(game.selected_slot)
  ignore(dom_request_animation_frame(t => game_frame(game, t)))
}

///|
fn game_frame(game : Game, time : Double) -> Unit {
  let raw_dt = (time - game.last_time) / 1000.0
  let dt = clamp(raw_dt, 0.0, 0.05)
  game.last_time = time
  let dims = dom_resize_canvas(game.renderer.canvas)
  game.renderer.resize(dims[0], dims[1])
  update_camera(game, dt, time)
  update_hotbar(game)
  handle_block_edit(game)
  update_particles(game, dt)
  upload_particles(game)
  process_chunk_queue(game)
  flush_dirty_chunks(game)
  let aspect = if game.renderer.height > 0 {
    game.renderer.width.to_double() / game.renderer.height.to_double()
  } else {
    1.0
  }
  let far_plane = (game.view_distance * game.chunk_size).to_double() * 3.0
  let projection = mat4_perspective(1.0, aspect, 0.1, far_plane)
  let front = camera_front(game.camera)
  let view = mat4_look_at(
    game.camera.position,
    game.camera.position.add(front),
    vec3(0.0, 1.0, 0.0),
  )
  let vp = mat4_mul(projection, view)
  let underwater = player_eye_in_water(game.world, game.camera.position)
  let tint = if underwater {
    vec3(0.08, 0.2, 0.35)
  } else {
    vec3(0.0, 0.0, 0.0)
  }
  let tint_strength = if underwater { 0.45 } else { 0.0 }
  game.renderer.draw(vp, tint, tint_strength)
  ignore(dom_request_animation_frame(t => game_frame(game, t)))
}

///|
fn update_camera(game : Game, dt : Double, now : Double) -> Unit {
  let sensitivity = 0.002
  let dx = input_mouse_dx()
  let dy = input_mouse_dy()
  game.camera.yaw = game.camera.yaw + dx * sensitivity
  game.camera.pitch = clamp(game.camera.pitch - dy * sensitivity, -1.55, 1.55)
  let fly_mode = input_fly_enabled()
  let fly_speed_mul = 3.0
  let mut direction = vec3(0.0, 0.0, 0.0)
  let yaw = game.camera.yaw
  let forward_flat = vec3(@math.cos(yaw), 0.0, @math.sin(yaw)).normalize()
  let right = forward_flat.cross(vec3(0.0, 1.0, 0.0)).normalize()
  let w_down = input_key("KeyW")
  if w_down {
    direction = direction.add(forward_flat)
  }
  if input_key("KeyS") {
    direction = direction.sub(forward_flat)
  }
  if input_key("KeyA") {
    direction = direction.sub(right)
  }
  if input_key("KeyD") {
    direction = direction.add(right)
  }
  if w_down && !game.last_w_down {
    if now - game.last_w_tap < 260.0 {
      game.sprint_latch = true
    }
    game.last_w_tap = now
  }
  if !w_down {
    game.sprint_latch = false
  }
  game.last_w_down = w_down
  let mut pos = game.camera.position
  let cmd = input_consume_command()
  if cmd.length() > 0 {
    let code = cmd[0]
    if code == 1 && cmd.length() >= 4 {
      let x = cmd[1]
      let y = cmd[2]
      let z = cmd[3]
      pos = vec3(x.to_double() + 0.5, y.to_double() + 2.0, z.to_double() + 0.5)
      game.camera.vel_y = 0.0
      game.player_height = standing_height()
    } else if code == 2 && cmd.length() >= 3 {
      let x = cmd[1]
      let z = cmd[2]
      let h = game.world.height_at(x, z)
      pos = vec3(x.to_double() + 0.5, h.to_double() + 2.0, z.to_double() + 0.5)
      game.camera.vel_y = 0.0
      game.player_height = standing_height()
    } else if code == 3 && cmd.length() >= 3 {
      let yaw_deg = cmd[1]
      let pitch_deg = cmd[2]
      game.camera.yaw = yaw_deg.to_double() * @math.PI / 180.0
      game.camera.pitch = clamp(
        pitch_deg.to_double() * @math.PI / 180.0,
        -1.55,
        1.55,
      )
    }
  }
  if input_consume_teleport() {
    pos = random_spawn(game)
    game.camera.vel_y = 0.0
    game.player_height = standing_height()
  }
  let sneak_key = input_key("ShiftLeft") || input_key("ShiftRight")
  let sneaking = if fly_mode { false } else { sneak_key }
  let sprinting = !sneak_key &&
    (input_key("ControlLeft") || input_key("ControlRight") || game.sprint_latch)
  let target_height = if sneaking {
    sneaking_height()
  } else {
    standing_height()
  }
  if target_height != game.player_height {
    let delta = game.player_height - target_height
    let candidate = vec3(pos.x, pos.y - delta, pos.z)
    if position_free(game.world, candidate, target_height) {
      pos = candidate
      game.player_height = target_height
    }
  }
  let mut on_ground = player_on_ground(game.world, pos, game.player_height)
  let mut in_water = player_in_water(game.world, pos, game.player_height)
  if fly_mode {
    in_water = false
  }
  if direction.length() > 0.0 {
    let base_speed = if fly_mode { 11.0 * fly_speed_mul } else { 5.2 }
    let sprint_speed = if fly_mode { 17.0 * fly_speed_mul } else { 7.6 }
    let sneak_speed = 2.6
    let water_speed = 2.8
    let speed = if sneaking {
      sneak_speed
    } else if sprinting {
      sprint_speed
    } else if in_water {
      water_speed
    } else {
      base_speed
    }
    let air_control = if fly_mode || on_ground { 1.0 } else { 0.7 }
    let velocity = direction.normalize().mul(speed * air_control * dt)
    let next_x = vec3(pos.x + velocity.x, pos.y, pos.z)
    if position_free(game.world, next_x, game.player_height) {
      if !sneaking ||
        !on_ground ||
        player_on_ground(game.world, next_x, game.player_height) {
        pos = next_x
      }
    }
    let next_z = vec3(pos.x, pos.y, pos.z + velocity.z)
    if position_free(game.world, next_z, game.player_height) {
      if !sneaking ||
        !on_ground ||
        player_on_ground(game.world, next_z, game.player_height) {
        pos = next_z
      }
    }
  }
  on_ground = player_on_ground(game.world, pos, game.player_height)
  in_water = player_in_water(game.world, pos, game.player_height)
  if fly_mode {
    in_water = false
  }
  if on_ground && game.camera.vel_y < 0.0 {
    game.camera.vel_y = 0.0
  }
  if fly_mode {
    let mut vertical = 0.0
    if input_key("Space") {
      vertical = vertical + 1.0
    }
    if sneak_key {
      vertical = vertical - 1.0
    }
    if vertical != 0.0 {
      let fly_speed = if sprinting { 12.0 } else { 8.5 }
      let next_y = vec3(
        pos.x,
        pos.y + vertical * fly_speed * fly_speed_mul * dt,
        pos.z,
      )
      if position_free(game.world, next_y, game.player_height) {
        pos = next_y
      }
    }
    game.camera.vel_y = 0.0
  } else if in_water {
    let gravity = 6.0
    let swim_speed = 3.0
    if input_key("Space") {
      game.camera.vel_y = swim_speed
    } else if sneaking {
      game.camera.vel_y = -swim_speed
    } else {
      game.camera.vel_y = game.camera.vel_y - gravity * dt
    }
    game.camera.vel_y = game.camera.vel_y * 0.85
    if game.camera.vel_y < -3.0 {
      game.camera.vel_y = -3.0
    }
  } else {
    let gravity = 24.0
    let jump_speed = 8.0
    if on_ground && input_key("Space") {
      game.camera.vel_y = jump_speed
      on_ground = false
    } else if !on_ground {
      let mut g = gravity
      if game.camera.vel_y > 0.0 && !input_key("Space") {
        g = gravity * 1.6
      }
      game.camera.vel_y = game.camera.vel_y - g * dt
    }
  }
  let next_y = vec3(pos.x, pos.y + game.camera.vel_y * dt, pos.z)
  if position_free(game.world, next_y, game.player_height) {
    pos = next_y
  } else {
    if game.camera.vel_y < 0.0 {
      let mut probe = pos
      let step = -0.05
      let mut candidate = vec3(probe.x, probe.y + step, probe.z)
      while position_free(game.world, candidate, game.player_height) {
        probe = candidate
        candidate = vec3(probe.x, probe.y + step, probe.z)
      }
      pos = probe
      on_ground = true
    }
    game.camera.vel_y = 0.0
  }
  game.camera.position = clamp_position(game.world, pos, game.player_height)
  let block_x = game.camera.position.x.floor().to_int()
  let block_z = game.camera.position.z.floor().to_int()
  let cx = div_floor(block_x, game.chunk_size)
  let cz = div_floor(block_z, game.chunk_size)
  if cx != game.current_cx || cz != game.current_cz {
    game.current_cx = cx
    game.current_cz = cz
    refresh_visible_chunks(game)
  }
}

///|
fn handle_block_edit(game : Game) -> Unit {
  let max_dist = 8.0
  let origin = game.camera.position
  let dir = camera_front(game.camera)
  let hit_place = raycast(game.world, origin, dir, max_dist)
  let hit_break = raycast_solid(game.world, origin, dir, max_dist)
  match hit_break {
    Some(hit) =>
      if input_consume_click(0) {
        let broken = game.world.get(hit.x, hit.y, hit.z)
        if !block_is_replaceable(broken) {
          if game.world.set(hit.x, hit.y, hit.z, Block::Air) {
            mark_block_dirty(game, hit.x, hit.z)
            spawn_break_particles(game, hit.x, hit.y, hit.z, broken)
          }
        }
      }
    None => ()
  }
  match hit_place {
    Some(hit) =>
      if input_consume_click(2) {
        let target = game.world.get(hit.x, hit.y, hit.z)
        let place_x = if block_is_replaceable(target) {
          hit.x
        } else {
          hit.prev_x
        }
        let place_y = if block_is_replaceable(target) {
          hit.y
        } else {
          hit.prev_y
        }
        let place_z = if block_is_replaceable(target) {
          hit.z
        } else {
          hit.prev_z
        }
        if block_is_replaceable(game.world.get(place_x, place_y, place_z)) &&
          !block_overlaps_player(
            game.camera,
            place_x,
            place_y,
            place_z,
            game.player_height,
          ) {
          if game.world.set(place_x, place_y, place_z, game.selected_block) {
            mark_block_dirty(game, place_x, place_z)
          }
        }
      }
    None => ()
  }
}

///|
fn update_hotbar(game : Game) -> Unit {
  if input_key("Digit1") {
    set_hotbar(game, 0, Block::Grass)
  }
  if input_key("Digit2") {
    set_hotbar(game, 1, Block::Dirt)
  }
  if input_key("Digit3") {
    set_hotbar(game, 2, Block::Stone)
  }
  if input_key("Digit4") {
    set_hotbar(game, 3, Block::Sand)
  }
  if input_key("Digit5") {
    set_hotbar(game, 4, Block::Wood)
  }
  if input_key("Digit6") {
    set_hotbar(game, 5, Block::Brick)
  }
  if input_key("Digit7") {
    set_hotbar(game, 6, Block::Planks)
  }
  if input_key("Digit8") {
    set_hotbar(game, 7, Block::Leaves)
  }
  if input_key("Digit9") {
    set_hotbar(game, 8, Block::Cobblestone)
  }
  if input_key("Digit0") {
    set_hotbar(game, 9, Block::Gravel)
  }
  if input_key("Minus") {
    set_hotbar(game, 10, Block::Clay)
  }
  if input_key("Equal") {
    set_hotbar(game, 11, Block::Snow)
  }
}

///|
fn rand_next(game : Game) -> Double {
  let mut state = game.rng_state
  state = state * 1103515245 + 12345
  game.rng_state = state
  let v = (state >> 1) & 0x7fffffff
  v.to_double() / 2147483647.0
}

///|
fn spawn_break_particles(
  game : Game,
  x : Int,
  y : Int,
  z : Int,
  block : Block,
) -> Unit {
  if block_is_air(block) {
    return
  }
  let (tx, ty) = particle_tile(block)
  let base_color = block_particle_color(block)
  let origin = vec3(
    x.to_double() + 0.5,
    y.to_double() + 0.5,
    z.to_double() + 0.5,
  )
  for i = 0; i < 18; i = i + 1 {
    let rx = rand_next(game) * 2.0 - 1.0
    let ry = rand_next(game) * 1.2
    let rz = rand_next(game) * 2.0 - 1.0
    let velocity = vec3(rx, ry + 0.6, rz).mul(3.0)
    let life = 0.6 + rand_next(game) * 0.4
    let size = 0.08 + rand_next(game) * 0.05
    game.particles.push({
      position: origin,
      velocity,
      life,
      size,
      color: base_color,
      tile_x: tx,
      tile_y: ty,
    })
  }
}

///|
fn update_particles(game : Game, dt : Double) -> Unit {
  if game.particles.length() == 0 {
    return
  }
  let alive : Array[Particle] = []
  for i = 0; i < game.particles.length(); i = i + 1 {
    let p = game.particles[i]
    let velocity = p.velocity.add(vec3(0.0, -12.0 * dt, 0.0))
    let position = p.position.add(velocity.mul(dt))
    let life = p.life - dt
    if life > 0.0 {
      alive.push({
        position,
        velocity,
        life,
        size: p.size,
        color: p.color,
        tile_x: p.tile_x,
        tile_y: p.tile_y,
      })
    }
  }
  game.particles = alive
}

///|
fn upload_particles(game : Game) -> Unit {
  if game.particles.length() == 0 {
    game.renderer.clear_particles()
    return
  }
  let front = camera_front(game.camera)
  let right = front.cross(vec3(0.0, 1.0, 0.0)).normalize()
  let up = right.cross(front).normalize()
  let positions : Array[Double] = []
  let colors : Array[Double] = []
  let uvs : Array[Double] = []
  for i = 0; i < game.particles.length(); i = i + 1 {
    let p = game.particles[i]
    let r = right.mul(p.size)
    let u = up.mul(p.size)
    let c = p.position
    let p0 = c.sub(r).sub(u)
    let p1 = c.add(r).sub(u)
    let p2 = c.add(r).add(u)
    let p3 = c.sub(r).add(u)
    let (u0, v0, u1, v1) = particle_uv(p.tile_x, p.tile_y)
    push_particle_quad(
      positions,
      colors,
      uvs,
      p0,
      p1,
      p2,
      p3,
      p.color,
      u0,
      v0,
      u1,
      v1,
    )
  }
  let mesh = { positions, colors, uvs, vertex_count: positions.length() / 3 }
  game.renderer.upload_particles(mesh)
}

///|
fn push_particle_quad(
  positions : Array[Double],
  colors : Array[Double],
  uvs : Array[Double],
  p0 : Vec3,
  p1 : Vec3,
  p2 : Vec3,
  p3 : Vec3,
  color : Vec3,
  u0 : Double,
  v0 : Double,
  u1 : Double,
  v1 : Double,
) -> Unit {
  push_particle_vertex(positions, colors, uvs, p0, color, u0, v0)
  push_particle_vertex(positions, colors, uvs, p1, color, u1, v0)
  push_particle_vertex(positions, colors, uvs, p2, color, u1, v1)
  push_particle_vertex(positions, colors, uvs, p0, color, u0, v0)
  push_particle_vertex(positions, colors, uvs, p2, color, u1, v1)
  push_particle_vertex(positions, colors, uvs, p3, color, u0, v1)
}

///|
fn push_particle_vertex(
  positions : Array[Double],
  colors : Array[Double],
  uvs : Array[Double],
  p : Vec3,
  color : Vec3,
  u : Double,
  v : Double,
) -> Unit {
  positions.push(p.x)
  positions.push(p.y)
  positions.push(p.z)
  colors.push(color.x)
  colors.push(color.y)
  colors.push(color.z)
  uvs.push(u)
  uvs.push(v)
}

///|
fn particle_uv(tile_x : Int, tile_y : Int) -> (Double, Double, Double, Double) {
  let cols = 4.0
  let rows = 4.0
  let du = 1.0 / cols
  let dv = 1.0 / rows
  let u0 = tile_x.to_double() * du
  let v1 = 1.0 - tile_y.to_double() * dv
  let v0 = v1 - dv
  (u0, v0, u0 + du, v1)
}

///|
fn particle_tile(block : Block) -> (Int, Int) {
  match block {
    Block::Grass => (0, 0)
    Block::Dirt => (2, 0)
    Block::Stone => (3, 0)
    Block::Sand => (0, 1)
    Block::Water => (1, 1)
    Block::Wood => (2, 1)
    Block::Leaves => (0, 2)
    Block::Bedrock => (1, 2)
    Block::Planks => (2, 2)
    Block::Brick => (3, 2)
    Block::Cobblestone => (0, 3)
    Block::Gravel => (1, 3)
    Block::Clay => (2, 3)
    Block::Snow => (3, 3)
    Block::Air => (0, 0)
  }
}

///|
fn block_particle_color(block : Block) -> Vec3 {
  match block {
    Block::Grass => vec3(0.8, 1.0, 0.8)
    Block::Dirt => vec3(0.9, 0.75, 0.6)
    Block::Stone => vec3(0.9, 0.9, 0.9)
    Block::Sand => vec3(1.0, 0.95, 0.8)
    Block::Water => vec3(0.8, 0.9, 1.0)
    Block::Wood => vec3(0.9, 0.8, 0.6)
    Block::Leaves => vec3(0.8, 1.0, 0.8)
    Block::Bedrock => vec3(0.7, 0.7, 0.7)
    Block::Planks => vec3(1.0, 0.9, 0.75)
    Block::Brick => vec3(1.0, 0.75, 0.75)
    Block::Cobblestone => vec3(0.85, 0.85, 0.85)
    Block::Gravel => vec3(0.8, 0.78, 0.75)
    Block::Clay => vec3(0.85, 0.88, 0.95)
    Block::Snow => vec3(0.95, 0.97, 1.0)
    Block::Air => vec3(1.0, 1.0, 1.0)
  }
}

///|
fn set_hotbar(game : Game, slot : Int, block : Block) -> Unit {
  if game.selected_slot != slot {
    game.selected_slot = slot
    game.selected_block = block
    dom_set_hotbar(slot)
  }
}

///|

///|
fn visible_coords_ring(cx : Int, cz : Int, dist : Int) -> Array[(Int, Int)] {
  let coords : Array[(Int, Int)] = []
  coords.push((cx, cz))
  for r = 1; r <= dist; r = r + 1 {
    let x0 = cx - r
    let x1 = cx + r
    let z0 = cz - r
    let z1 = cz + r
    for x = x0; x <= x1; x = x + 1 {
      coords.push((x, z0))
      coords.push((x, z1))
    }
    if z1 - z0 > 1 {
      for z = z0 + 1; z <= z1 - 1; z = z + 1 {
        coords.push((x0, z))
        coords.push((x1, z))
      }
    }
  }
  coords
}

///|
fn empty_mesh() -> Mesh {
  { positions: [], colors: [], uvs: [], vertex_count: 0 }
}

///|
fn empty_chunk_mesh() -> ChunkMesh {
  { solid: empty_mesh(), cutout: empty_mesh(), trans: empty_mesh() }
}

///|
fn in_detail_range(game : Game, cx : Int, cz : Int) -> Bool {
  let dx = (cx - game.current_cx).abs()
  let dz = (cz - game.current_cz).abs()
  dx <= game.detail_distance && dz <= game.detail_distance
}

///|
fn clamp_int(v : Int, min_val : Int, max_val : Int) -> Int {
  if v < min_val {
    min_val
  } else if v > max_val {
    max_val
  } else {
    v
  }
}

///|
fn detail_y_bounds(game : Game) -> (Int, Int) {
  let center = game.camera.position.y.floor().to_int()
  let range = game.world.section_height * 3
  let y0 = clamp_int(center - range, 0, game.world.height)
  let mut y1 = clamp_int(center + range, 0, game.world.height)
  if y1 <= y0 {
    y1 = clamp_int(y0 + 1, 0, game.world.height)
  }
  (y0, y1)
}

///|
fn chunk_terrain_bounds(game : Game, cx : Int, cz : Int) -> (Int, Int) {
  let mut min_h = game.world.height
  let mut max_h = 0
  let x0 = cx * game.chunk_size
  let z0 = cz * game.chunk_size
  for dz = -1; dz <= game.chunk_size; dz = dz + 1 {
    let gz = z0 + dz
    for dx = -1; dx <= game.chunk_size; dx = dx + 1 {
      let gx = x0 + dx
      let h = game.world.column_info(gx, gz).height
      if h < min_h {
        min_h = h
      }
      if h > max_h {
        max_h = h
      }
    }
  }
  if max_h < game.world.sea_level {
    max_h = game.world.sea_level
  }
  let margin = 4
  let y0 = clamp_int(min_h - margin, 0, game.world.height)
  let mut y1 = clamp_int(max_h + margin, 0, game.world.height)
  if y1 <= y0 {
    y1 = clamp_int(y0 + 1, 0, game.world.height)
  }
  (y0, y1)
}

///|
fn chunk_detail_bounds(game : Game, cx : Int, cz : Int) -> (Int, Int) {
  let (cam0, cam1) = detail_y_bounds(game)
  let (t0, t1) = chunk_terrain_bounds(game, cx, cz)
  let y0 = if t0 < cam0 { t0 } else { cam0 }
  let y1 = if t1 > cam1 { t1 } else { cam1 }
  (y0, y1)
}

///|
fn build_chunk_keep_set(
  game : Game,
  coords : Array[(Int, Int)],
) -> @hashmap.HashMap[String, Bool] {
  let cache_dist = game.detail_distance + 1
  let keep = @hashmap.new(capacity=coords.length() * 6 + 1)
  for i = 0; i < coords.length(); i = i + 1 {
    let (cx, cz) = coords[i]
    let dx = (cx - game.current_cx).abs()
    let dz = (cz - game.current_cz).abs()
    if dx <= cache_dist && dz <= cache_dist {
      let (y0, y1) = chunk_detail_bounds(game, cx, cz)
      let cy0 = div_floor(y0, game.world.section_height)
      let cy1 = div_floor(y1 - 1, game.world.section_height)
      let mut cy = cy0
      while cy <= cy1 {
        keep.set(chunk_key_3(cx, cy, cz), true)
        cy = cy + 1
      }
    }
  }
  keep
}

///|
fn build_height_keep_set(
  game : Game,
  coords : Array[(Int, Int)],
) -> @hashmap.HashMap[String, Bool] {
  let cache_dist = game.detail_distance + 1
  let keep = @hashmap.new(capacity=coords.length() + 1)
  for i = 0; i < coords.length(); i = i + 1 {
    let (cx, cz) = coords[i]
    let dx = (cx - game.current_cx).abs()
    let dz = (cz - game.current_cz).abs()
    if dx <= cache_dist && dz <= cache_dist {
      keep.set(chunk_key(cx, cz), true)
    }
  }
  keep
}

///|
fn enqueue_surface(game : Game, cx : Int, cz : Int) -> Unit {
  let key = chunk_key(cx, cz)
  match game.queued_surface.get(key) {
    Some(_) => ()
    None => {
      game.queued_surface.set(key, true)
      game.chunk_queue_surface.push((cx, cz))
    }
  }
}

///|
fn enqueue_detail(game : Game, cx : Int, cz : Int) -> Unit {
  let key = chunk_key(cx, cz)
  match game.queued_detail.get(key) {
    Some(_) => ()
    None => {
      game.queued_detail.set(key, true)
      game.chunk_queue_detail.push((cx, cz))
    }
  }
}

///|
fn process_chunk_queue(game : Game) -> Unit {
  let mut built = 0
  let budget = game.chunk_build_budget
  let start = dom_now_ms()
  while built < budget {
    let mut did_work = false
    if game.chunk_queue_surface_cursor < game.chunk_queue_surface.length() {
      let (cx, cz) = game.chunk_queue_surface[game.chunk_queue_surface_cursor]
      game.chunk_queue_surface_cursor = game.chunk_queue_surface_cursor + 1
      let key = chunk_key(cx, cz)
      match game.visible_index.get(key) {
        Some(idx) => {
          let mesh = build_chunk_surface_mesh(
            game.world,
            cx,
            cz,
            game.chunk_size,
          )
          game.renderer.update_chunk(idx, mesh)
          if idx >= 0 && idx < game.slot_detail.length() {
            game.slot_detail[idx] = false
          }
        }
        None => ()
      }
      game.queued_surface.remove(key)
      if game.initial_loading {
        game.initial_built = game.initial_built + 1
        if game.initial_total > 0 {
          let progress = game.initial_built.to_double() /
            game.initial_total.to_double()
          if progress >= 1.0 {
            game.initial_loading = false
            dom_set_loading(1.0)
          } else {
            dom_set_loading(progress)
          }
        } else {
          game.initial_loading = false
          dom_set_loading(1.0)
        }
      }
      did_work = true
    } else if game.chunk_queue_detail_cursor < game.chunk_queue_detail.length() {
      let (cx, cz) = game.chunk_queue_detail[game.chunk_queue_detail_cursor]
      game.chunk_queue_detail_cursor = game.chunk_queue_detail_cursor + 1
      let key = chunk_key(cx, cz)
      match game.visible_index.get(key) {
        Some(idx) => {
          let (y0, y1) = chunk_detail_bounds(game, cx, cz)
          let mesh = build_chunk_mesh(
            game.world,
            cx,
            cz,
            game.chunk_size,
            y0,
            y1,
          )
          game.renderer.update_chunk(idx, mesh)
          if idx >= 0 && idx < game.slot_detail.length() {
            game.slot_detail[idx] = true
          }
        }
        None => ()
      }
      game.queued_detail.remove(key)
      did_work = true
    }
    if !did_work {
      break
    }
    built = built + 1
    if dom_now_ms() - start >= game.chunk_time_budget_ms {
      break
    }
  }
  if game.chunk_queue_surface_cursor >= game.chunk_queue_surface.length() {
    game.chunk_queue_surface = []
    game.chunk_queue_surface_cursor = 0
    game.queued_surface = @hashmap.new(
      capacity=game.visible_index.length() * 2 + 1,
    )
  }
  if game.chunk_queue_detail_cursor >= game.chunk_queue_detail.length() {
    game.chunk_queue_detail = []
    game.chunk_queue_detail_cursor = 0
    game.queued_detail = @hashmap.new(
      capacity=game.visible_index.length() * 2 + 1,
    )
  }
}

///|
fn refresh_visible_chunks(game : Game) -> Unit {
  let coords = visible_coords_ring(
    game.current_cx,
    game.current_cz,
    game.view_distance,
  )
  if game.slot_coords.length() == 0 {
    let meshes : Array[ChunkMesh] = []
    for i = 0; i < coords.length(); i = i + 1 {
      meshes.push(empty_chunk_mesh())
    }
    game.renderer.upload_chunk_meshes(meshes)
    game.visible_index = @hashmap.new(capacity=coords.length() * 2 + 1)
    for i = 0; i < coords.length(); i = i + 1 {
      let (cx, cz) = coords[i]
      let key = chunk_key(cx, cz)
      game.visible_index.set(key, i)
    }
    game.slot_coords = coords
    game.slot_detail = Array::make(coords.length(), false)
    game.dirty_chunks = []
    game.chunk_queue_surface = []
    game.chunk_queue_surface_cursor = 0
    game.queued_surface = @hashmap.new(capacity=coords.length() * 2 + 1)
    game.chunk_queue_detail = []
    game.chunk_queue_detail_cursor = 0
    game.queued_detail = @hashmap.new(capacity=coords.length() * 2 + 1)
    game.initial_loading = true
    game.initial_total = coords.length()
    game.initial_built = 0
    dom_set_loading(0.0)
    for i = 0; i < coords.length(); i = i + 1 {
      let (cx, cz) = coords[i]
      enqueue_surface(game, cx, cz)
      if in_detail_range(game, cx, cz) {
        enqueue_detail(game, cx, cz)
      }
    }
    let keep = build_chunk_keep_set(game, coords)
    let keep_height = build_height_keep_set(game, coords)
    game.world.prune_chunks(keep)
    game.world.prune_height_cache(keep_height)
    return
  }
  let visible_set = @hashmap.new(capacity=coords.length() * 2 + 1)
  for i = 0; i < coords.length(); i = i + 1 {
    let (cx, cz) = coords[i]
    visible_set.set(chunk_key(cx, cz), true)
  }
  let pending_surface : Array[(Int, Int)] = []
  for i = game.chunk_queue_surface_cursor
      i < game.chunk_queue_surface.length()
      i = i + 1 {
    let (cx, cz) = game.chunk_queue_surface[i]
    let key = chunk_key(cx, cz)
    if visible_set.contains(key) {
      pending_surface.push((cx, cz))
    }
  }
  game.chunk_queue_surface = pending_surface
  game.chunk_queue_surface_cursor = 0
  game.queued_surface = @hashmap.new(capacity=coords.length() * 2 + 1)
  for i = 0; i < game.chunk_queue_surface.length(); i = i + 1 {
    let (cx, cz) = game.chunk_queue_surface[i]
    game.queued_surface.set(chunk_key(cx, cz), true)
  }
  let pending_detail : Array[(Int, Int)] = []
  for i = game.chunk_queue_detail_cursor
      i < game.chunk_queue_detail.length()
      i = i + 1 {
    let (cx, cz) = game.chunk_queue_detail[i]
    let key = chunk_key(cx, cz)
    if visible_set.contains(key) {
      pending_detail.push((cx, cz))
    }
  }
  game.chunk_queue_detail = pending_detail
  game.chunk_queue_detail_cursor = 0
  game.queued_detail = @hashmap.new(capacity=coords.length() * 2 + 1)
  for i = 0; i < game.chunk_queue_detail.length(); i = i + 1 {
    let (cx, cz) = game.chunk_queue_detail[i]
    game.queued_detail.set(chunk_key(cx, cz), true)
  }
  let next_index = @hashmap.new(capacity=coords.length() * 2 + 1)
  let used = Array::make(game.slot_coords.length(), false)
  let pending : Array[(Int, Int)] = []
  for i = 0; i < coords.length(); i = i + 1 {
    let (cx, cz) = coords[i]
    let key = chunk_key(cx, cz)
    match game.visible_index.get(key) {
      Some(idx) => {
        next_index.set(key, idx)
        used[idx] = true
      }
      None => pending.push((cx, cz))
    }
  }
  let free_slots : Array[Int] = []
  for i = 0; i < used.length(); i = i + 1 {
    if !used[i] {
      free_slots.push(i)
    }
  }
  let mut cursor = 0
  for i = 0; i < pending.length(); i = i + 1 {
    if cursor >= free_slots.length() {
      break
    }
    let idx = free_slots[cursor]
    cursor = cursor + 1
    let (cx, cz) = pending[i]
    game.renderer.update_chunk(idx, empty_chunk_mesh())
    game.slot_coords[idx] = (cx, cz)
    if idx >= 0 && idx < game.slot_detail.length() {
      game.slot_detail[idx] = false
    }
    let key = chunk_key(cx, cz)
    next_index.set(key, idx)
    enqueue_surface(game, cx, cz)
    if in_detail_range(game, cx, cz) {
      enqueue_detail(game, cx, cz)
    }
  }
  game.visible_index = next_index
  for i = 0; i < coords.length(); i = i + 1 {
    let (cx, cz) = coords[i]
    let key = chunk_key(cx, cz)
    match game.visible_index.get(key) {
      Some(idx) => {
        let want_detail = in_detail_range(game, cx, cz)
        if want_detail && !game.slot_detail[idx] {
          enqueue_detail(game, cx, cz)
        } else if !want_detail && game.slot_detail[idx] {
          enqueue_surface(game, cx, cz)
        }
      }
      None => ()
    }
  }
  let keep = build_chunk_keep_set(game, coords)
  let keep_height = build_height_keep_set(game, coords)
  game.world.prune_chunks(keep)
  game.world.prune_height_cache(keep_height)
}

///|
fn flush_dirty_chunks(game : Game) -> Unit {
  if game.dirty_chunks.length() == 0 {
    return
  }
  for i = 0; i < game.dirty_chunks.length(); i = i + 1 {
    let (cx, cz) = game.dirty_chunks[i]
    let key = chunk_key(cx, cz)
    match game.visible_index.get(key) {
      Some(idx) =>
        if in_detail_range(game, cx, cz) {
          let (y0, y1) = chunk_detail_bounds(game, cx, cz)
          let mesh = build_chunk_mesh(
            game.world,
            cx,
            cz,
            game.chunk_size,
            y0,
            y1,
          )
          game.renderer.update_chunk(idx, mesh)
          if idx >= 0 && idx < game.slot_detail.length() {
            game.slot_detail[idx] = true
          }
        } else {
          let mesh = build_chunk_surface_mesh(
            game.world,
            cx,
            cz,
            game.chunk_size,
          )
          game.renderer.update_chunk(idx, mesh)
          if idx >= 0 && idx < game.slot_detail.length() {
            game.slot_detail[idx] = false
          }
        }
      None => ()
    }
  }
  game.dirty_chunks = []
}

///|
fn mark_block_dirty(game : Game, x : Int, z : Int) -> Unit {
  let cx = div_floor(x, game.chunk_size)
  let cz = div_floor(z, game.chunk_size)
  let lx = mod_floor(x, game.chunk_size)
  let lz = mod_floor(z, game.chunk_size)
  mark_chunk_dirty(game, cx, cz)
  if lx == 0 {
    mark_chunk_dirty(game, cx - 1, cz)
  } else if lx == game.chunk_size - 1 {
    mark_chunk_dirty(game, cx + 1, cz)
  }
  if lz == 0 {
    mark_chunk_dirty(game, cx, cz - 1)
  } else if lz == game.chunk_size - 1 {
    mark_chunk_dirty(game, cx, cz + 1)
  }
}

///|
fn mark_chunk_dirty(game : Game, cx : Int, cz : Int) -> Unit {
  for i = 0; i < game.dirty_chunks.length(); i = i + 1 {
    if game.dirty_chunks[i].0 == cx && game.dirty_chunks[i].1 == cz {
      return
    }
  }
  game.dirty_chunks.push((cx, cz))
}

///|
fn camera_front(camera : Camera) -> Vec3 {
  let cp = @math.cos(camera.pitch)
  let sp = @math.sin(camera.pitch)
  let cy = @math.cos(camera.yaw)
  let sy = @math.sin(camera.yaw)
  vec3(cp * cy, sp, cp * sy).normalize()
}

///|
fn player_radius() -> Double {
  0.3
}

///|
fn standing_height() -> Double {
  1.6
}

///|
fn sneaking_height() -> Double {
  1.2
}

///|
fn player_on_ground(world : World, pos : Vec3, height : Double) -> Bool {
  let probe = vec3(pos.x, pos.y - 0.05, pos.z)
  !position_free(world, probe, height)
}

///|
fn player_in_water(world : World, pos : Vec3, height : Double) -> Bool {
  let radius = player_radius()
  let ix0 = (pos.x - radius).floor().to_int()
  let ix1 = (pos.x + radius).floor().to_int()
  let iz0 = (pos.z - radius).floor().to_int()
  let iz1 = (pos.z + radius).floor().to_int()
  let iy0 = (pos.y - height).floor().to_int()
  let iy1 = pos.y.floor().to_int()
  let mut y = iy0
  while y <= iy1 {
    if block_is_water(world.get(ix0, y, iz0)) ||
      block_is_water(world.get(ix0, y, iz1)) ||
      block_is_water(world.get(ix1, y, iz0)) ||
      block_is_water(world.get(ix1, y, iz1)) {
      return true
    }
    y = y + 1
  }
  false
}

///|
fn player_eye_in_water(world : World, pos : Vec3) -> Bool {
  let ix = pos.x.floor().to_int()
  let iz = pos.z.floor().to_int()
  let iy = pos.y.floor().to_int()
  block_is_water(world.get(ix, iy, iz))
}

///|
fn position_free(world : World, pos : Vec3, height : Double) -> Bool {
  let radius = player_radius()
  let ix0 = (pos.x - radius).floor().to_int()
  let ix1 = (pos.x + radius).floor().to_int()
  let iz0 = (pos.z - radius).floor().to_int()
  let iz1 = (pos.z + radius).floor().to_int()
  let iy0 = (pos.y - height).floor().to_int()
  let iy1 = pos.y.floor().to_int()
  let mut y = iy0
  while y <= iy1 {
    let layer_ok = !block_is_solid(world.get(ix0, y, iz0)) &&
      !block_is_solid(world.get(ix0, y, iz1)) &&
      !block_is_solid(world.get(ix1, y, iz0)) &&
      !block_is_solid(world.get(ix1, y, iz1))
    if !layer_ok {
      return false
    }
    y = y + 1
  }
  true
}

///|
fn clamp_position(world : World, pos : Vec3, height : Double) -> Vec3 {
  let min_y = height
  let max_y = (world.height - 1).to_double() + 0.8
  vec3(pos.x, clamp(pos.y, min_y, max_y), pos.z)
}

///|
fn block_overlaps_player(
  camera : Camera,
  x : Int,
  y : Int,
  z : Int,
  height : Double,
) -> Bool {
  let radius = player_radius()
  let min_x = camera.position.x - radius
  let max_x = camera.position.x + radius
  let min_y = camera.position.y - height
  let max_y = camera.position.y
  let min_z = camera.position.z - radius
  let max_z = camera.position.z + radius
  let bx0 = x.to_double()
  let by0 = y.to_double()
  let bz0 = z.to_double()
  let bx1 = bx0 + 1.0
  let by1 = by0 + 1.0
  let bz1 = bz0 + 1.0
  !(max_x <= bx0 ||
  min_x >= bx1 ||
  max_y <= by0 ||
  min_y >= by1 ||
  max_z <= bz0 ||
  min_z >= bz1)
}