> For the complete documentation index, see [llms.txt](https://claude.fractalgamestudios.es/llms.txt). Markdown versions of documentation pages are available by appending `.md` to page URLs; this page is available as [Markdown](https://claude.fractalgamestudios.es/bolas/game-info/customizacion-robux-gratis-y-hud-de-combate-parte-1.md).

# Customización, Robux gratis y HUD de combate (parte 1)

### 2026-08-01 (reescritura completa del sistema de customización + nuevo sistema de ProximityPrompts — EN CURSO)

**Objetivo (via /goal):** Reescribir desde cero el sistema de customización de bolas (el actual "a duras penas funciona") para que sea 100% data-driven/customizable, conectado de verdad con la UI en React-Roblox que ya está empezando la compañera de UX, y crear un nuevo sistema de ProximityPrompts para los terminales `UserTerminal_Display`.

#### Diagnóstico completo (exploración vía MCP, sin gastar contexto en tocar nada todavía)

Encontrado un desastre de **tres modelos de datos distintos y no sincronizados** para la misma cosa (bolas/skins), más una UI en React desconectada de todo:

1. **Dato real persistido** (`ServerStorage.Managers.PlayerManager`, ProfileStore): `data.Balls[weaponId] = {IsEquipped, Rebirths, Stats, SkinsUnlocked (array plano), Selected={Ball={Id,Color}, Fx={Id,Color}, Trail={Id,Color}, Weapon={Id,Variant}}}`. Solo `Unarmed` está activo en la plantilla; las otras 8 armas (`WeaponData.Weapons`: Sword, Dagger, Bow, Spear, Scythe, Katana, Staff, Scepter) están comentadas y se dan vía `PlayerManager.giveBall`.
2. **Catálogo de skins real** (`ServerStorage.Data.SkinConfig`): data-driven correcto (BallSkin/WeaponSkin/FxSkin/TrailSkin, cada uno con `UsedBy` = lista de weaponIds que pueden usarlo), pero muy incompleto (solo 2 skins de arma definidas, comentarios del propio autor tipo "faltan attachments", "qué pasa con los packs").
3. **Aplicación real a la bola 3D** (`ServerStorage.Classes.BallSystem.Customization.lua`, invocado por `ServerScriptService.Controllers.CustomizationController` vía remote `Customization/ShowYourBall`): funciona a medias. **Bugs confirmados:**
   * El **Trail nunca se aplica** — `trailSkinConfig` se lee de `SkinConfig.TrailSkin[...]` pero jamás se usa en `SetupSkin`.
   * El **FX no limpia el anterior** — cada vez que se re-customiza, clona un sonido/partícula nuevo sin borrar el anterior (fuga de instancias acumulándose en la bola).
   * `Customization.GetWeaponTemplate(weaponType, weaponSkinId, weaponVariant)` se define con 3 parámetros pero `CustomizationController` lo llama con solo 2 (`ballId, weaponSkinId`) — la variante nunca se pasa, bug de firma.
   * El arma real solo se re-colorea (`SetWeapon`), nunca se intercambia el modelo de skin real del arma.
4. **Backend "nuevo" para el front React** (`ServerScriptService.Controllers.BallsController`, 202 líneas): usa nombres de campo que **no existen** en el dato real de PlayerManager (`ball.isOwned`, `ball.isEquipped` en minúscula, `ball.skinsUnlocked[slot]`, `ball.equippedSkins[slot]`) — nunca ha funcionado contra datos reales, es un stub a medio escribir con un modelo de datos inventado y distinto del real.
5. **Front React** (`ReplicatedStorage.Front.Controllers.BallsController`, 799 líneas): tiene tipos ricos y bien pensados (`BallData`, `IBallSkinConfig`, rareza, stats/maxStats, upgrades, rebirths) pero está **permanentemente en `MOCK_MODE = true`** con datos hardcodeados — la UI de la compañera nunca ha recibido un dato real del servidor.
6. **UI React ya existente**: `Front.Router` (UIRouter + routes.lua, con capas sticky/main/overlay/modal — bien diseñado) ya registra rutas `BallsConfig` y `BallInventory` apuntando a `Front.Modules.BallsConfig` y `Front.Modules.BallInventory`. `BallInventory` está bastante avanzado (grid + panel de detalles + stats). `BallsConfig` tiene más de la mitad del código **comentado/muerto** (selectors de parte, color picker, diamantes de stats — todo escrito pero deshabilitado).
7. **Terminal actual** (`StarterPlayer.StarterPlayerScripts.Controllers.CustomizationController`): hardcodea un único `ProximityPrompt` en `Workspace.Lobby.ZonaCustomizacion.UserTerminal_Display` (WaitForChild de ruta fija, sin tags). Al activarse, pide al servidor una bola de preview y la muestra, pero dispara `Comm:FireLocal("Frontend/OpenCustomization")` — **evento que nadie escucha en todo el codebase** (verificado por grep). Es decir: la puerta de entrada a la customización está físicamente desconectada de la UI React. Además existe un **segundo terminal** (`Workspace.Lobby.terminal.UserTerminal_Display` + `UserTerminal_Body`) que no tiene ningún script, tag ni prompt — completamente huérfano.
8. **Patrón ya establecido en el codebase**: `CollectionService` + tags ya se usa en otros sitios (`AnimationController`, `WeaponSystem`, `BallFactory`, `Bow`/`Staff`) para lógica reactiva basada en tags en vez de rutas fijas — es el patrón correcto a seguir para el nuevo sistema de terminales en vez de `WaitForChild` hardcodeado.
9. Assets reales confirmados en `ServerStorage.Models.Skins`: `Weapons/{Bow,Dagger,Spear,Sword,Katana,Scythe,Staff,Scepter}` (carpetas), `Fx/{Sounds,Particles}`, `Trails/{Default_Trail, Polygonal_Trail}` (objetos `Trail` reales, listos para clonar).

#### Arquitectura de la reescritura (decidida, ejecutándose ahora)

* **`SkinConfig` se mueve/consolida en `ReplicatedStorage.Shared`** (single source of truth): es contenido puro (sin lógica de desbloqueo de jugador, eso sigue siendo autoridad exclusiva del servidor vía `SkinsUnlocked`/`Selected` en el profile), así que no hay problema de seguridad en que el cliente lo lea directamente — evita una réplica manual/remote extra y hace que la UI de React pueda pintar catálogos completos (precios, rareza, variantes) sin round-trip.
* **`Customization.lua`**: fix de los 3 bugs (Trail real, limpieza de FX antes de reaplicar, firma correcta de `GetWeaponTemplate` con variante) + de verdad intercambia el modelo de skin del arma, no solo el color.
* **`ServerScriptService.Controllers.BallsController`**: reescritura completa contra el modelo de datos REAL (`IsEquipped`/`SkinsUnlocked`/`Selected`), construyendo el snapshot para cada arma de `WeaponData.Weapons` (poseida o no) enriquecido con el catálogo de `SkinConfig` por slot. Acción unificada `Balls/SelectSkin {ballId, slot, skinId, variant?, color?}` que valida desbloqueo, guarda en el profile, re-llama a `Customization.SetupSkin` para refrescar la bola 3D en vivo, y notifica al cliente.
* **`ReplicatedStorage.Front.Controllers.BallsController`**: se elimina `MOCK_MODE` y todo el bloque de datos falsos (código muerto), queda conectado 100% a snapshots reales del servidor.
* **UI React (`BallsConfig`/`BallInventory`)**: se completan (no se tiran, son la base de la compañera de UX) rellenando los huecos muertos con pickers reales de skin/color/variante conectados al controller real.
* **Nuevo sistema de ProximityPrompts** (pedido explícito): controlador data-driven por tags de `CollectionService` (tag `CustomizationTerminal`) en vez de rutas fijas, que setea el prompt en cualquier instancia con ese tag de forma robusta a `StreamingEnabled` (reutilizando el patrón ya usado para los carteles de lobby en la sesión de 2026-08-01 anterior), inicializado en la primera carga del jugador. Sustituye al `LocalScript` actual y conecta de verdad con `UIRouter.navigate("BallInventory"/"BallsConfig")` en vez del evento muerto `Frontend/OpenCustomization`. Se aplica a AMBOS `UserTerminal_Display` (Lobby.terminal y Lobby.ZonaCustomizacion).
* **Fuera de alcance explícito por ahora** (no pedido, se documenta como limitación conocida): compra real con Robux (MarketplaceService/ProcessReceipt) — las skins `TypeAdquisition="Robux"` quedarán con validación de servidor pero sin flujo de compra real todavía; las de `"Dollars"` sí se podrán comprar de verdad (ya existe `PlayerManager.removeDollars`).

#### Estado de ejecución

* [x] Exploración completa (este documento).
* [x] Mover/consolidar `SkinConfig` a `ReplicatedStorage.Shared` (con `SlotConfigKey`, `GetSkinsForSlot`, `GetSkin`, `IsSkinUsableBy`). Verificados assets reales (`ServerStorage.Models.Skins.Weapons/Fx/Trails`) — el catálogo original YA coincidía con los assets, solo hacía falta consolidarlo y añadir los helpers.
* [x] Fix de `Customization.lua`: Trail real (`SetTrail`, nuevo, con 2 attachments dedicados `TrailAttachment0/1`), limpieza de FX antes de reaplicar (evita fuga de sonidos/partículas), `SetWeapon` corregido (buscaba `WeaponSystem_*` como hijo directo de la bola, algo que `WeaponSystem.createWeapon` NUNCA crea — el modelo real es `WeaponAttach.WeaponModel` — nunca se había recoloreado un arma en la vida del proyecto), nueva función compartida `Customization.EquipWeaponModel` (resuelve skin/variante con fallback si no está desbloqueada, invocada por `BallFactory` para partidas reales y por `CustomizationController` para el preview de terminal — antes esta lógica estaba duplicada y la copia de la terminal había divergido con un bug de firma, `GetWeaponTemplate` llamado con 2 args en vez de 3).
* [x] `ServerScriptService.Controllers.CustomizationController` reescrito: orden correcto (equipar arma ANTES de `SetupSkin`, si no el recoloreado del arma no encuentra nada que pintar), quitados requires muertos, **eliminado el handler `"Customization/TpTo"` que usaba `local goto = ...` — `goto` es palabra reservada en Luau, muy probablemente un error de sintaxis que rompía la carga de TODO el script** (nadie lo invocaba, así que nunca se detectó).
* [x] `ServerScriptService.Controllers.BallsController` reescrito desde 0 contra el modelo real de `PlayerManager` (antes usaba campos inventados que no existen: `isOwned`, `isEquipped` en minúscula, `skinsUnlocked[slot]`). Nuevo remote unificado `Balls/SelectSkin` (valida `SkinConfig.IsSkinUsableBy` + `SkinsUnlocked`, persiste, refresca la bola 3D en vivo si está equipada) y `Balls/UnlockSkin` (gasta Dollars reales vía `PlayerManager.removeDollars`; las skins `TypeAdquisition="Robux"` devuelven `ROBUX_PURCHASE_NOT_IMPLEMENTED` — documentado como límite conocido, requiere `MarketplaceService`/`ProcessReceipt`, fuera de alcance). Se mantiene intacto el remote legado `"ChangeWeapon"` (lo usan 9 `LocalScript`s reales en `StarterGui.BallsHud`, HUD de selección de arma distinto de la terminal de customización).
* [x] `ReplicatedStorage.Front.Controllers.BallsController` reescrito: eliminado `MOCK_MODE` y todo el bloque de datos falsos (\~500 líneas muertas). Contrato de snapshot rediseñado limpio (`selected`/`options` por slot en vez de los 4 shapes inconsistentes `ballskin/weaponSkin/fx/trail` que tenían distintos nombres de campo cada uno sin razón). `useBalls.lua` no necesitó cambios (misma API `isOwned`/`isEquipped`/`onChanged`).
* [x] Nuevo sistema de ProximityPrompts data-driven: tag `CollectionService` `"CustomizationTerminal"` (aplicado a AMBOS `UserTerminal_Display`, antes solo uno tenia prompt). Nuevo `StarterPlayerScripts.Controllers.CustomizationTerminalController` (el antiguo `CustomizationController` LocalScript queda deshabilitado y renombrado `_DEPRECATED`, no borrado, siguiendo la preferencia ya expresada por el usuario en sesiones previas). Calcula el punto de spawn del preview desde un `SpawnBall` hermano si existe, o si no con un offset por defecto delante del terminal (para que un terminal nuevo funcione sin tener que colocar una part extra a mano). Conectado de verdad a la UI: nuevo `Front.Router.RemoteRouteBridge` (componente React sin render, montado dentro del `UIRouter` en `App.lua`) escucha `"Frontend/OpenCustomization"`/`"Frontend/CloseCustomization"` y llama a `router.navigate("BallsConfig")`/`closeView(...)` — antes ese evento lo disparaba la terminal pero **nadie en toda la codebase lo escuchaba**.
* [x] Completar UI React (`BallsConfig`/`BallInventory`) con pickers reales conectados al nuevo contrato `selected`/`options`. `BallsConfig` estaba literalmente vacío (un `Frame` rojo sin contenido — 900+ líneas de un intento anterior comentadas y rotas contra un `Theme.lua` distinto). Reescrito desde 0: tabs por slot (Ball/Weapon/Fx/Trail), lista de opciones con estado owned/equipped/precio, swatches de color, selector de variante de arma, botón "CAMBIAR BOLA" → navega a `BallInventory`. `BallInventory` corregido: nombres de campo alineados al contrato nuevo (`ball.selected.Ball.color` en vez de `ball.skinConfig.ballskin.materialColor`), stats (`healthpoints`/`rotationspeed`), **bug real encontrado y arreglado: `Theme.Colors.PrimaryColor` no existe (solo `TextPrimaryColor`) — 6 `TextLabel` habrían fallado al renderizar**, `router.navigateTo` (no existe, es `router.navigate`) corregido, añadidos botones reales "EQUIPAR"/"PERSONALIZAR".
* [x] Playtest en Studio — **parcialmente validado, cortado por desconexión de Studio a mitad de prueba** (ver hallazgo y pendiente abajo).

**HALLAZGO CRÍTICO no relacionado con lo pedido pero bloqueante para ver cualquier cosa:** `StarterPlayer.StarterPlayerScripts.Views.Init` (el script que monta `Front.App`, la raíz de TODO el frontend React) tenía su código real **comentado a propósito**, con nota explícita: *"COMENTADO PARA OCULTAR EL FRONTEND A MEDIO HACER EN REACT. SE MANTIENEN FUNCIONALES LOS BOTONES NATIVOS PROVISIONALES Y EL LOBBY."* — es decir, alguien (¿la compañera de UX o "Black"? hay un comentario en `Theme.lua` línea 32: `"LAURA ARREGLA TU CODIGO ATT: BLACK"`) desactivó el frontend entero porque no estaba listo. **Lo reactivé** (descomentado el montaje real de `ReactRoblox.createRoot` + `App`) porque es exactamente lo que pide esta tarea — sin esto, nada de lo construido (terminal, BallsConfig, BallInventory) sería visible nunca.

Al reactivarlo até también un problema más amplio de la codebase (no introducido por mí, preexistente): **muchos componentes compartidos referencian colores de `Theme.Colors` que no existen** — `Primary`, `PrimaryDark`, `Surface`, `White`, `Info`, `Success`, `Error`, `Warning`, `TextPrimary` (sin "Color"), `TextSecondary` (sin "Color"), `Background`, `CardBackgroundColor`, `TextColor` — usados en `Front.Shared.Components.Buttons`, `Front.Modules.HUD.HUD`/`MoneyDisplay`, `Front.Shared.Components.ColorPicker`, `Front.Shared.Components.PlayerPreview`, `Front.Modules.BattleMenu.BattleMenuView`. El `Theme.lua` actual solo define `ContainerBgColor/ContainerBorderColor/PanelBgColor/HighlightColor/TextPrimaryColor/TextSecondaryColor/CardBgColor`. **Confirmado empíricamente en playtest que esto NO crashea** (una prop `nil` en una tabla de React simplemente no se aplica, la propiedad se queda en su valor por defecto de Roblox) — el HUD de dinero se veía y funcionaba pese a estas referencias rotas. Es decir: es un problema de **estética/consistencia visual pendiente**, no un bloqueante funcional. No lo he arreglado (toca módulos ajenos a la customización — Buttons, ColorPicker, BattleMenu — y decidir los valores de color correctos es una decisión de diseño de la compañera de UX, no mía). Documentado aquí para que se sepa que existe.

**Validado visualmente en playtest (antes del corte de conexión):**

* El `ProximityPrompt` "Customizar" aparece correctamente en el terminal (`Lobby.ZonaCustomizacion.UserTerminal_Display`) — confirma que el nuevo sistema de tags (`CustomizationTerminal`) funciona.
* Al simular la tecla E, el servidor generó correctamente la bola de preview con su arma equipada (visible en captura de pantalla).
* El HUD real (`$1010`, dólares reales del perfil) se renderiza correctamente con el frontend reactivado.
* Snapshot real de "Sword" verificado por `eval_client_runtime` (antes de reactivar el frontend): `isOwned=true`, `isEquipped=true`, `selected` y `options` con la forma exacta esperada, incluyendo un caso real de datos desincronizados de pruebas anteriores (`Selected.Weapon.Id="ImperialWeapon"` pero no está en `SkinsUnlocked` — el fallback a "DefaultWeapon" en `Customization.EquipWeaponModel` maneja esto correctamente).

**Bug encontrado durante la reescritura, arreglado antes de que hiciera falta depurarlo en producción:** el remote `"Balls/UnlockResult"` (nuevo, para notificar al cliente el resultado de desbloquear una skin) no estaba pre-registrado en `GameServer` — `CommunicationManager:ConnectClient` hace un `WaitForChild` SIN timeout, así que el cliente se quedaba colgado para siempre esperando ese remote en cuanto `Front.Controllers.BallsController` intentaba cargar, **bloqueando el arranque de TODO el frontend silenciosamente** (sin error, solo un hang). Arreglado añadiendo `CreateEvent("Balls/SelectSkin"|"Balls/UnlockSkin"|"Balls/UnlockResult"|"Balls/EquipBall")` a `GameServer`. **Lección para el futuro: cualquier evento nuevo que el cliente escuche con `ConnectClient` DEBE pre-registrarse en `GameServer` con `CreateEvent`, si no se cuelga el cliente sin avisar.**

#### Continuación tras recuperar la conexión (mismo día) — causa raíz encontrada y validación completa end-to-end

**Causa raíz del bug "No tienes ninguna bola equipada":** condición de carrera real. `Front.Controllers.BallsController` pide su snapshot inicial nada más cargar (`task.defer(requestSnapshot)`), pero el perfil del jugador en el servidor (`PlayerManager`/`ProfileStore`, `Store:StartSessionAsync`) puede tardar más en estar listo que eso. Cuando la petición llegaba antes, `ServerScriptService.Controllers.BallsController.sendSnapshot` respondía con el fallback `{ownedBalls = {}, equippedBall = nil}` (pensado para "jugador sin perfil", no para "perfil aún cargando") y el cliente se quedaba con ese estado vacío para siempre — no había ningún mecanismo de reintento. **Fix:** nuevo `waitAndSendInitialSnapshot` en el `BallsController` del servidor, enganchado a `Players.PlayerAdded` (+ jugadores ya presentes), que espera activamente (hasta 10s) a que `PlayerManager.getData` devuelva datos reales antes de enviar el snapshot — empuje proactivo, igual que ya hacía `PlayerManager` para el Wallet. Confirmado el fix con `eval_client_runtime`: `isReady=true, equippedId="Sword", count=9` en un playtest limpio.

**Bug menor encontrado y arreglado en el mismo pase:** el `UIGridLayout` de los swatches de color en `BallsConfig` no tenia `SortOrder = Enum.SortOrder.LayoutOrder`, así que Roblox los ordenaba alfabéticamente por el nombre (el hex) en vez de por el orden pensado — cada swatch sí tenia `LayoutOrder` puesto correctamente, solo faltaba decirle al layout que lo respetara.

**Validación completa end-to-end en playtest (con capturas, clicks y lectura de datos reales del profile):**

1. `Frontend/OpenCustomization` (disparado por la terminal) abre `BallsConfig` correctamente — captura confirmada: título "Sword", tabs BOLA/FX/ESTELA/ARMA, lista de opciones con EQUIPADO/precio en $/ROBUX, swatches de color en el orden correcto, botones CAMBIAR BOLA / CERRAR.
2. Click real (`simulate_mouse_input`) en un swatch de color → `PlayerManager` persiste `Selected.Ball.Color = "00fe02"` inmediatamente (confirmado leyendo el profile real por `eval_server_runtime`).
3. **La configuración se recupera correctamente**: se volvió a invocar `Customization/ShowYourBall` (la misma función que usa `BallFactory.createBall` para partidas reales) y la bola generada tuvo el color exacto `RGB(0, 254, 2)` = `#00fe02` — confirma que el pipeline de guardado/recuperación usado por la terminal es EL MISMO que usan las partidas reales (ambos pasan por `PlayerManager.getUserBall` → `Customization.SetupSkin`), así que cualquier cambio hecho en el panel de customización se refleja automáticamente en combate sin ninguna sincronización adicional.
4. Inspección de descendientes de la bola de preview confirma los 3 fixes de `Customization.lua` funcionando de verdad en conjunto: `WeaponModel` con `SM_Sword` (skin Imperial real equipada, no solo color), `HitSound`/`ParrySound`/`CustomFxParticle` presentes exactamente UNA vez cada uno (sin fugas), `TrailAttachment0`/`TrailAttachment1`/`CustomTrail` presentes (el Trail, que antes JAMÁS se aplicaba, ahora existe).
5. Sin errores ni warnings en los logs de servidor/cliente durante toda la prueba.

**Pendiente / fuera de alcance, sin bloquear el objetivo pedido:**

* Los colores de `Theme` que faltan en `Buttons`/`HUD`/`ColorPicker`/`BattleMenu` (no crashean, solo se ven con colores por defecto) — es una decisión de diseño visual de la compañera de UX, no se ha tocado.
* Compra real con Robux (`MarketplaceService`/`ProcessReceipt`) — solo validación de servidor, sin flujo de pago real.
* Sistema de mejora de stats/rebirth — no reconstruido, no era parte del pedido.

**Objetivo cumplido y validado:** el panel de customización abre el menú correctamente y las configuraciones elegidas se guardan (ProfileStore) y se recuperan tanto en la propia terminal como en partidas reales.

***

### 2026-08-01 (estela/FX/variantes de arma visualmente idénticos entre sí, nueva Lobby AFK para farmear Dollars sin bloquear el Lobby normal)

Nuevo `/goal`: la estela "Polygonal" (el usuario la llamó "Pentagonal" por error — no existe ninguna skin con ese nombre, confirmado buscando en todo el proyecto) se veía exactamente igual que "Plain Color"; el FX "Imperial" se veía igual que el "Default"; y las variantes de arma (A/B/C) no cambiaban nada visualmente al seleccionarlas. Además, pidió una Lobby AFK: una zona separada, sin areas de combate ni teleports a partidas, donde los jugadores a punto de ser expulsados por inactividad por Roblox puedan aparecer, moverse, customizar su bola y ganar Dollars por el tiempo que pasen alli, pudiendo volver al Lobby normal cuando quieran.

#### 1-3. Estela, FX y variantes de arma "no cambian" — mismo patrón de causa raíz

Los tres bugs resultaron ser el mismo problema de contenido, no de código: las plantillas de las skins "premium" eran clones literales de la skin base, nunca diferenciados visualmente por quien las creó.

* **`Polygonal_Trail`**: propiedades (Color, Transparency, sin Texture) idénticas byte a byte a `Default_Trail` (confirmado comparando `get_instance_properties` de ambas). Además, `Customization.SetTrail` sobrescribe SIEMPRE `trail.Color` con el color elegido por el jugador — así que ni siquiera recolorear la plantilla habría servido. Arreglado dándole una forma realmente distinta vía `WidthScale` (propiedad que el código nunca toca): una secuencia de picos/valles que crea un contorno facetado/anguloso en vez de la cinta lisa de Default, más `LightEmission` para un brillo extra.
* **`Imperial_Particle`** (FX): sí tenía un `Texture` propio (`rbxassetid://118599866901502`, distinto del `sparkles_main.dds` de Default) y un `Size`/`Lifetime` algo mayores, pero el asset devolvía 404 en la Open Cloud API y "thumbnail no disponible" — sospechoso de estar roto/no accesible. En vez de apostar por un asset que podría no cargar en el cliente, se reforzó la diferencia con propiedades 100% fiables que `Customization.SetFX` tampoco toca: `Rate` 1→3, `SpreadAngle` (0,0)→(15,15), `RotSpeed` 0→(0,120), `LightEmission` 0→0.6 — ahora es inconfundiblemente más denso/brillante/dinámico que el Default, independientemente de si esa textura concreta carga o no.
* **Variantes de arma (A/B/C)**: `DefaultWeapon_A` y `DefaultWeapon_B` (y las demás letras, en los 8 tipos de arma) usan el mismísimo `MeshId`, mismo Material, mismo Color — variante B es un clon sin editar de la A. Igual que con la estela, `Customization.SetWeapon` sobrescribe siempre `.Color` con el color elegido por el jugador, así que solo `Material` sobrevive. Recorrido todo `ServerStorage.Models.Skins.Weapons` por script: cualquier modelo terminado en `_B` pasa a `Material = Metal`, cualquiera en `_C` pasa a `Material = Neon` (17 modelos / 21 parts tocados, cubriendo los 8 tipos de arma + `ImperialWeapon_B` de Sword). La variante A se deja tal cual (aspecto base).

Validado en playtest: desde la terminal de customización, alternar Ball/Weapon a través de estos slots muestra ahora material/forma claramente distintos; capturas de pantalla confirmaron el cambio visual real (espada con acabado Metal visible, plataforma con la estela facetada).

#### 4. Lobby AFK

Objetivo: cuando Roblox está a punto de desconectar a un jugador por inactividad, moverlo a una zona aislada donde estar AFK no estorbe ni rompa nada, y recompensarlo por el tiempo que pase alli.

**Dato clave que cambió el diseño**: `Players.PlayerIdled` (lo que yo esperaba usar en servidor) **no existe** — la consulta a la documentación oficial de Roblox reveló que el evento real es `Player.Idled(time)` y **solo existe en el cliente**; el servidor no tiene forma de enterarse por si solo. Ademas, Roblox no desconecta a los 2 minutos: desconecta a los **20 minutos** de inactividad, y `Player.Idled` empieza a disparar a los \~2 minutos de inactividad y se repite cada 30s mientras siga inactivo. Para avisar "2 minutos antes del límite de 20", hay que esperar a que el tiempo acumulado llegue a los 18 minutos (1080s) y ahi avisar al servidor por remote.

**Arquitectura implementada**:

* `StarterPlayer.StarterPlayerScripts.Controllers.AFKIdleWatcher` (LocalScript nuevo): escucha `player.Idled`, y en cuanto `idleTime >= 1080` dispara el remote `AFKLobby/PlayerIdle` (registrado en `GameServer.lua`). Sin logica de negocio, solo avisa.
* `ServerScriptService.Controllers.AFKLobbyController` (Script nuevo): al recibir el aviso, si el jugador NO esta en plena partida real (no existe su `<Nombre>_ball` en Workspace — reutiliza la convencion de nombres ya establecida en todo el proyecto para no romper partidas activas), lo teletransporta a `Workspace.AFKLobby`. Cada 60s, mientras siga alli, le da 5 Dollars (`PlayerManager.addDollars`). El regreso voluntario usa `ProximityPromptService.PromptTriggered` en servidor (sin necesidad de ningun remote ni script de cliente adicional) sobre un prompt "Volver a la Lobby" en la propia zona.
* `Workspace.AFKLobby` (zona nueva): una plataforma flotante 500 studs por encima del Lobby principal (mismo X/Z que su spawn), con muros invisibles en el borde (a esa altura, un jugador realmente AFK no deberia poder caerse por un empujon), un punto de teleport, un prompt "Volver a la Lobby", y un **clon exacto** del terminal de customizacion real (`Workspace.Lobby.terminal`). El clon conserva automaticamente el tag de CollectionService `"CustomizationTerminal"` (Clone() copia los tags) — `CustomizationTerminalController` (ya existente, data-driven por tag) lo detecta solo y le monta el ProximityPrompt, sin tocar ningun script de cliente para eso. No se incluyó ningún hitbox de partida (`Lobby.Interactables`) en la zona — por eso el panel de union a partidas nunca aparece alli, sin necesidad de logica de supresion extra: no hay ningún hitbox cerca del que activarse.

Validado en playtest completo: disparado el remote manualmente → el jugador se teletransportó de Y≈141 (Lobby) a Y≈643 (AFK Lobby); esperados los 60s del ciclo de recompensa → log confirmó `+5 Dollars` y el wallet subió de $3310 a $3315; capturado el prompt "Volver a la Lobby" visible e interactuado con **E** real (`simulate_keyboard_input`) → el jugador volvió a Y≈141; abierta la terminal de customización clonada en la zona AFK con **E** → el panel de React se abrió con normalidad, mostrando la bola equipada (Sword, con el acabado Metal del fix de variantes visible). Sin errores en `get_runtime_logs` durante toda la prueba.

**Nota de depuración**: el primer intento de probar el retorno con `simulate_keyboard_input` no funcionó — no fue un bug real, el prompt de Roblox aun no habia terminado de aparecer/activarse en pantalla cuando se envio la tecla. Repetido tras confirmar visualmente (captura de pantalla) que el prompt ya estaba activo, funcionó a la primera.

**Limitación conocida (documentada en el propio script)**: si un jugador queda AFK mientras está en cola (todavía sin bola de combate) y la partida arranca justo mientras está en la zona AFK, no hay ningún hook para traerlo de vuelta automáticamente — caso raro, fuera de alcance por ahora.

**Archivos nuevos:** `StarterPlayer.StarterPlayerScripts.Controllers.AFKIdleWatcher`, `ServerScriptService.Controllers.AFKLobbyController`, `Workspace.AFKLobby` (+ terminal clonado). **Archivos tocados:** `ServerScriptService.GameServer` (registro del remote `AFKLobby/PlayerIdle`), `ServerStorage.Models.Skins.Trails.Polygonal_Trail` (WidthScale/LightEmission), `ServerStorage.Models.Skins.Fx.Particles.Imperial_Particle` (Rate/SpreadAngle/RotSpeed/LightEmission), `ServerStorage.Models.Skins.Weapons.*` (Material de todas las variantes B/C).

***

### 2026-08-01 (artículos de Robux gratis en Studio y para fundadores, sin tocar nunca la base de datos)

Nuevo `/goal`: hacer que los artículos de customización que cuestan Robux sean gratis al jugar desde Studio (para poder probarlos sin gastar Robux reales) y también gratis de forma permanente para los dueños/fundadores del juego — pero en ningún caso debía quedar rastro de esto en el perfil/base de datos real (ni siquiera para los fundadores).

#### Estado previo

`BallsController.unlockSkin` ya distinguía `TypeAdquisition` ("Dollars"/"Robux"/"Event") pero para "Robux" simplemente devolvía el error `"ROBUX_PURCHASE_NOT_IMPLEMENTED"` — la integración real de `MarketplaceService`/`ProcessReceipt` para skins nunca se llegó a hacer (sí existe un framework genérico de compras, `PurchaseManager`, pero ninguna skin está registrada en él). No existía ninguna lista de UserId de administradores/fundadores en todo el proyecto.

#### Por qué "no guardar en base de datos" no es trivial aquí

`PlayerManager.getData(player)` devuelve `profile.Data` **por referencia** (no una copia) — es la tabla real que `ProfileStore` autoguarda periódicamente y siempre al salir el jugador. Cualquier mutación directa de esa tabla (aunque no se llame explícitamente a `PlayerManager.saveData`) acaba persistida tarde o temprano. Además el equipo confirmó en sesiones anteriores que en este proyecto Studio pega contra el **mismo DataStore real** que producción (`ProfileStore` no usa un mock en Studio). Por tanto la única forma correcta de que esto "no se guarde" es no tocar `profile.Data` en ningún momento para este flujo — ni para el desbloqueo, ni para la selección/equipar.

#### Solución: overlay en memoria, completamente separado del perfil

Dos módulos nuevos, ambos en `ServerStorage.Managers` (server-only, nunca se replican al cliente, por lo que ya son inherentemente seguros frente a exploits):

* **`FounderAccess`**: tabla vacía `{ [UserId] = true }` pensada para que el propio usuario la edite a mano añadiendo los UserId de los fundadores. No la rellené yo (no tengo forma de conocer UserIds de Roblox reales) — el usuario pidió explícitamente un archivo de servidor que él mismo rellena, en vez de que yo le pida los UserId.
* **`RobuxFreeAccess`**: `HasFreeAccess(player)` = `RunService:IsStudio() or FounderAccess[player.UserId]`. Mantiene un `overlays[player][ballId] = { unlockedSkins, unlockedVariants, selected }` puramente en memoria (se pierde al reiniciar el servidor o al salir el jugador, vía `PlayerRemoving`). La función clave, `GetEffectiveBallInfo(player, ballId, info)`, nunca muta `info`: si hay overlay activo, construye y devuelve una **copia superficial nueva** con `SkinsUnlocked`/`WeaponsVariantsUnlocked`/`Selected` fusionados; si no hay overlay, devuelve `info` tal cual (no-op).

Puntos de integración (todos leen/escriben en el overlay, nunca en `info`/`profile.Data`, para los artículos con acceso gratuito):

* `BallsController.unlockSkin`: rama `Robux` ahora concede vía `RobuxFreeAccess.GrantSkin` si `HasFreeAccess`, devuelve `ok=true` sin tocar `info.SkinsUnlocked` ni llamar a `saveData`. Sigue devolviendo `ROBUX_PURCHASE_NOT_IMPLEMENTED` para cualquier otro jugador, sin cambios.
* `BallsController.selectSkin`: si el skin solo está desbloqueado vía overlay (no en el `SkinsUnlocked` real), la selección se guarda en `RobuxFreeAccess.SetSelected` en vez de `info.Selected`/`saveData`.
* `BallsController.buildBallEntry`/`computeSnapshot`: ahora fusionan el overlay antes de construir el snapshot que ve el cliente, así el panel de customización muestra el artículo como `owned`/`equipped` correctamente.
* `Customization.SetupSkin`: fusiona el overlay justo después de leer `PlayerManager.getUserBall` — cubre a la vez la bola de previsualización de la terminal Y las bolas reales de partida (`BallFactory` llama a `SetupSkin` para jugadores reales).
* `Customization.EquipWeaponModel` (llamado desde `CustomizationController` y `BallFactory`): en vez de tocar la función en sí, se fusiona el overlay en el `info` que se le pasa justo antes de la llamada, en ambos sitios — así una skin/variante de arma Robux gratuita también se ve correctamente equipada.

#### Validación en playtest (Studio, misma cuenta con acceso gratis por `IsStudio()`)

* Perfil real ANTES: `Sword.SkinsUnlocked` sin `NeonBall`/`ImperialWeapon`, `Selected.Ball = MetalBall`.
* Desbloqueo real vía el remote del cliente (`Balls/UnlockSkin` para `NeonBall` en Ball y `ImperialWeapon` en Weapon, ambos Robux): `ok=true` para ambos.
* Selección real vía `Balls/SelectSkin` (incluye variante "B" del arma): `ok=true`; snapshot del cliente muestra `NeonBall` como `owned=true, equipped=true`.
* Vista previa de la terminal (`Customization/ShowYourBall`) recreada después: material del `BasePart` = `Neon` (antes `Metal`), modelo de arma `WeaponModel` presente bajo `WeaponAttach` (plantilla `ImperialWeapon_B` resuelta correctamente).
* Perfil real DESPUÉS de todo esto: **sin cambios** — `Sword.SkinsUnlocked` sigue sin `NeonBall` ni `ImperialWeapon`, `Selected.Ball` sigue siendo `MetalBall`. Cero escritura en la base de datos real, exactamente lo pedido.
* Regresión: reintentar desbloquear `MetalBall` (ya poseida de verdad, vía Dollars) devuelve correctamente `ALREADY_UNLOCKED` — el camino de compra real con Dollars no se rompió con la reestructuración.
* Sin errores ni warnings en `get_runtime_logs` durante toda la prueba.

**Archivos nuevos:** `ServerStorage.Managers.FounderAccess` (lista de UserId, la rellena el usuario), `ServerStorage.Managers.RobuxFreeAccess` (lógica de acceso gratuito en memoria). **Archivos tocados:** `ServerScriptService.Controllers.BallsController` (unlockSkin/selectSkin/buildBallEntry/computeSnapshot/sendSnapshot), `ServerStorage.Classes.BallSystem.Customization` (SetupSkin), `ServerScriptService.Controllers.CustomizationController` (EquipWeaponModel), `ServerStorage.Classes.BallSystem.BallFactory` (EquipWeaponModel).

**Pendiente para cuando se implemente la compra real de Robux:** este sistema es explícitamente temporal (así lo pidió el usuario) — cuando se integre `MarketplaceService`/`ProcessReceipt` de verdad para las skins, `RobuxFreeAccess` debería seguir usándose solo como bypass de Studio/fundadores (no eliminarse), pero la rama `Robux` de `unlockSkin` para el resto de jugadores pasaría de devolver `ROBUX_PURCHASE_NOT_IMPLEMENTED` a lanzar `MarketplaceService:PromptProductPurchase`.

***

### 2026-08-01 (el anti-atasco anterior no detectaba el atasco real, sonido de flechas ensordecedor y sin parar al ganar, panel de unión apareciendo en plena partida, stats de rivales)

Nuevo `/goal`: el sistema "caída fallida" de la sesión anterior no arreglaba el bug real (bolas atascadas \~3 minutos en línea vertical/horizontal); el sonido de las flechas del Arco es ensordecedor y no para tras ganar; el panel de unión al lobby apareció en plena partida; y los rivales seguian sin mostrar las estadísticas detalladas del arma en el HUD de combate (solo se aplicó a la fila propia la sesión anterior, pese a la intención).

#### 1. Anti-atasco — causa raíz del fallo anterior encontrada

El detector de la sesión anterior solo miraba la **velocidad instantánea** (`<1 stud/s` = "atascada"). No servía para el caso real: una bola que sigue "moviéndose" a velocidad normal pero **deslizando en paralelo contra una pared** (o rebotando entre el mismo punto), cuya velocidad instantánea nunca cae por debajo del umbral aunque su posición apenas cambie — exactamente el patrón "línea recta fija durante minutos" descrito. **Fix real:** sustituido por un detector de **desplazamiento neto**: cada 2 segundos se mide cuánto se ha alejado realmente la bola de donde estaba; si el desplazamiento es menor de 8 studs (muy por debajo de lo que cualquier bola en movimiento normal recorre en ese tiempo), se considera atascada de verdad — con independencia de su velocidad instantánea — y se le da el empujón fuerte en dirección aleatoria. Validado en playtest: monitorizado el desplazamiento real de una bola en movimiento normal durante 10 segundos (6-26 studs/s, siempre muy por encima del umbral) — cero falsos positivos, el detector no molesta al movimiento normal.

#### 2. Sonido del Arco — ensordecedor y no paraba al ganar

Dos bugs reales en `WeaponScaling/Bow.lua`:

* **Bucle de disparo no comprobaba `IsFrozen`**, solo `Dead` — una bola ganadora (congelada en el centro del ring al terminar la partida, ver sesión anterior) seguía disparando ráfagas de flechas sin parar durante toda la pantalla de resultados. Mismo bug encontrado y arreglado también en `Staff.lua` (bolas de fuego), que comparte el mismo patrón de bucle.
* **Volumen sin límite combinado con ráfagas que escalan sin tope** (`BowArrowsPerBurst` sube con cada acierto): decenas de copias del mismo sonido a volumen completo sonando a la vez. Volumen de cada copia limitado explícitamente a 0.35.

#### 3. Panel de unión apareciendo en plena partida

Causa: en una partida LOCAL el personaje físico nunca se mueve del Lobby (la partida es "virtual": cámara bloqueada + bola de combate aparte) — si el jugador estaba de pie cerca de un hitbox al empezar la partida, el panel de unión (nuevo esta sesión) seguía apareciendo/desapareciendo con la detección de proximidad, sin que hubiera forma de interactuar con él con sentido. **Fix:** `LobbyVisuals.lua` reutiliza `Arena/SetCamera` (ya se dispara al empezar/terminar cualquier partida real) para saber si el jugador está jugando, y suprime el panel de unión mientras tanto. Validado en playtest: personaje colocado exactamente sobre el hitbox de "1v1" y con una partida activa — el panel no aparece.

#### 4. Estadísticas de rivales en el HUD de combate

La sesión anterior dejó el cálculo de estadísticas detalladas (`WEAPON_STAT_PROVIDERS`) condicionado a `isSelf`, así que solo la fila propia las mostraba pese a que la intención era mostrarlas para todos. Quitada la condición: ahora cualquier fila (propia o rival) calcula y muestra sus estadísticas de arma reales. Validado en playtest: partida 1v1 con bot con Arco — la fila del rival mostró correctamente "Arco" + "Flechas por ráfaga: 3", la propia "Espada" + "Daño por golpe: 2.0".

**Archivos tocados:** `ServerStorage.Classes.BallSystem.BallFactory` (detector de desplazamiento neto en vez de velocidad instantánea), `ServerStorage.Classes.WeaponScaling.Bow` (respeta `IsFrozen`, volumen limitado), `ServerStorage.Classes.WeaponScaling.Staff` (respeta `IsFrozen`), `StarterPlayer.StarterPlayerScripts.LobbyVisuals` (panel de unión suprimido durante partida activa), `StarterPlayer.StarterPlayerScripts.CombatHUD` (estadísticas detalladas para todas las filas, no solo la propia).

***

### 2026-08-01 (panel de unión personalizado, HUD de combate para todos los combatientes, anti-atasco de bolas, cola con un solo modo a la vez)

Nuevo `/goal`, seis pedidos: (1) los `ProximityPrompt` nativos del lobby "son horribles", usar un HUD propio; (2) el HUD de combate solo mostraba la bola propia, debería mostrar también a los rivales; (3) el "bug de línea vertical/horizontal" seguía pasando, pedido un sistema de "caída fallida" para que las bolas nunca se queden fijas; (4) el indicador de cola era tan estrecho que el texto se partía en 2 líneas; (5) investigar por qué el sonido deja de reproducirse en una segunda prueba sin reiniciar Studio; (6) entrar en una partida debería cancelar búsquedas anteriores, y no debería poder buscarse en 2 colas de modos distintos a la vez sin avisar.

#### 1. ProximityPrompts nativos → panel de unión propio

`LobbyVisuals.lua` reescrito: quitados los 2 `ProximityPrompt` (E/F) sobre cada hitbox. En su lugar, el mismo patrón de detección por proximidad (`Heartbeat`, como antes de los prompts) controla un **panel propio** con el estilo del resto del juego (tarjeta redondeada, dos botones "🏠 LOCAL" / "🌍 GLOBAL") que aparece/desaparece cerca del hitbox — sigue sin bloquear el HUD ni el movimiento.

#### 2. HUD de combate — ahora muestra a todos los combatientes

`CombatHUD.lua` reescrito: antes enganchaba una sola tarjeta a la bola propia; ahora escanea `workspace` en busca de **todas** las bolas de la partida activa (atributo `BallIndex`) y crea una fila por cada una, añadiendo/quitando filas en vivo según aparecen/mueren. La fila propia se destaca (borde blanco, ⭐) y es la única que despliega las estadísticas detalladas del arma (igual que antes); las de los rivales muestran solo nombre + vida + arma, para no saturar la pantalla. Validado en playtest: partida 1v1 con bot, las dos filas visibles desde el primer segundo con sus barras de vida y arma correctas.

#### 3. Bolas atascadas — investigado a fondo, sin repro de atasco real, añadido sistema defensivo igualmente

Monitorizada la velocidad de ambas bolas segundo a segundo durante partidas completas: nunca se observó una bola realmente parada (velocidad 0 sostenida) fuera de los congelamientos intencionados de golpe (0.1-0.3s). Aun así, el pequeño reajuste de dirección existente (±45° cuando la velocidad cae por debajo de 0.5) puede no bastar si una bola queda físicamente encajada (esquina, entre pared y rival) durante más tiempo. **Nuevo sistema "caída fallida":** cada bola lleva un cronómetro de "tiempo parada"; si supera 1.5s seguidos por debajo de 1 stud/s, se le fuerza un empujón fuerte en una dirección completamente nueva y aleatoria, aplicado directamente a su velocidad (no solo a la dirección deseada), garantizando que ninguna bola se quede fija de verdad, esté o no genuinamente atascada.

#### 4. Indicador de cola — ahora en una sola línea

Causa: el `TextLabel` del texto de cola ocupaba todo el alto de la píldora (46px) — con ese margen vertical de sobra, `TextScaled` prefería partir el texto en 2 líneas legibles antes que encoger la fuente a una sola línea diminuta. **Fix:** píldora ensanchada de 300 a 420px, y el `TextLabel` limitado a una altura fija de una sola línea (20px) para que `TextScaled` ya no tenga margen vertical para partir el texto. Confirmado en playtest: "1v1 · Local · 5s" en una sola línea, legible.

#### 5. Sonido que deja de reproducirse en una segunda prueba — investigado, sin bug de código reproducible

Ejecutadas 2 partidas seguidas en la misma sesión de Studio (sin parar el playtest entre medias), comprobando directamente el estado real (`Sound.Playing`, `Sound.IsLoaded`) de todos los sonidos activos en ambas — en las dos pruebas los sonidos se creaban y quedaban con `Playing = true` correctamente. Revisado también `ReplicatedStorage.Managers.AudioManager` (un gestor de sonido con nombre "por si acaso" sospechoso) — resultó ser **código completamente huérfano, no lo usa ningún script del proyecto**, descartado como causa. Los sonidos de golpe reales (`Unarmed`/`Bow`/`Staff`/etc., clonados por golpe y limpiados con `Debris`) no mostraron ninguna diferencia de comportamiento entre la primera y la segunda prueba en las comprobaciones directas del servidor. Sin poder "escuchar" audio directamente con las herramientas disponibles, no se pudo confirmar ni descartar del todo un problema real de audibilidad — la hipótesis más plausible, dado que el desencadenante exacto es "sin reiniciar Studio", es una limitación del propio motor de audio de Roblox Studio en sesiones de prueba largas (similar a la ya documentada limitación de `TeleportService` en Studio), no un bug del código del proyecto. Documentado para revisar si se reproduce en el juego publicado.

#### 6. Cola con un solo modo a la vez — bug real encontrado y arreglado

Causa: `Lobby/JoinQueue` comprobaba la cola LOCAL antes de unir a la GLOBAL (`if not LocalLobbyService.IsPlayerBusy(player) then GlobalMM.AddPlayer(...)`), pero **nunca comprobaba al revés** — unirse a LOCAL nunca miraba si ya se estaba en la cola GLOBAL. Un jugador podía acabar en dos colas de modos distintos sin que el servidor lo impidiera, y en cualquier caso el intento se ignoraba en silencio sin avisar. **Fix:** nueva `GlobalMatchmakingService.IsPlayerQueued(player)`, y el handler de `Lobby/JoinQueue` ahora comprueba SIEMPRE en ambos sentidos antes de aceptar cualquier unión; si el jugador ya está en una cola, se rechaza y se le avisa explícitamente con un nuevo remote `Lobby/JoinQueueDenied` (el cliente lo muestra como un aviso temporal de 3s). Validado en playtest: unido a la cola local de "1v1", el intento de unirse también a la global mostró correctamente "Ya estás buscando partida. Cancela la cola actual antes de buscar otra."

**Archivos tocados:** `StarterPlayer.StarterPlayerScripts.LobbyVisuals` (panel de unión propio, píldora ensanchada, aviso de cola duplicada), `StarterPlayer.StarterPlayerScripts.CombatHUD` (todas las bolas de la partida, no solo la propia), `ServerStorage.Classes.BallSystem.BallFactory` (sistema "caída fallida" anti-atasco), `ServerScriptService.Controllers.LobbyController` (comprobación de cola en ambos sentidos + aviso), `ServerScriptService.Managers.GlobalMatchmakingService` (`IsPlayerQueued`), `ServerScriptService.GameServer` (nuevo remote `Lobby/JoinQueueDenied`).

**Pendiente:** confirmar en el juego publicado si el bug de sonido de la segunda prueba se reproduce fuera de Studio (si es una limitación de Studio, no debería aparecer nunca en producción).

***

### 2026-08-01 (HUD de combate lateral por arma, quitado el número de vida provisional, y encontrado el origen real del "bug de la línea negra")

Nuevo `/goal`: 3 capturas nuevas + pedido de un HUD lateral de combate (vida, arma, estadísticas propias de esa arma — ej. Unarmed acelera y pega más fuerte cuanto más rápido va, Sword suma daño plano por golpe) visible solo para quien está peleando, sustituyendo al número de vida flotante sobre la bola ("eso era provisional").

#### Capturas revisadas

* **"Manera muy fea de ver el matchmaking.png"**: los 2 `ProximityPrompt` (Local/Global) del rediseño de la sesión anterior quedaban demasiado juntos (40px de separación total) y se solapaban. Separación ampliada a 110px.
* **"Bug movimiento vertical.png" / "Bug movimiento horizontal.png"**: ver más abajo — esta vez sí se encontró la causa raíz real.

#### HUD de combate lateral (nuevo)

Investigadas las 9 armas (`ServerStorage.Classes.WeaponScaling`) para saber qué estadísticas expone cada una realmente vía atributos en la bola: `Unarmed` (Speed +5/golpe sin tope, daño escala con la velocidad — coincide exactamente con la descripción del usuario), `Sword` (`CurrentDamage` +1/golpe), `Dagger` (velocidad de giro +5/golpe hasta 490, luego resetea y suma una vuelta), `Spear` (`CurrentDamage` +0.5/golpe, el arma crece de tamaño), `Scepter` (roba vida al golpear enemigos, cura aliados en modo equipo), `Scythe`/`Katana` (apilan veneno/cortes que hacen daño repartido en el tiempo), `Staff` (nivel de bola de fuego, sube con cada explosión), `Bow` (flechas por ráfaga, sube con cada acierto).

Nuevo `StarterPlayer.StarterPlayerScripts.CombatHUD`: tarjeta anclada al lateral derecho, con barra de vida (cambia de color verde/ámbar/rojo según % restante), nombre del arma equipada, y de 1 a 2 filas de estadísticas específicas de esa arma (tabla `WEAPON_STAT_PROVIDERS`, una función por arma que lee sus atributos propios). Se engancha al remote `Arena/SetCamera` que YA se dispara al empezar/terminar la partida real (`ArenaManager.StartMatch`/`EndRound`) — no hizo falta ningún remote nuevo. Solo se activa para el propio jugador y solo mientras tiene una bola de partida real viva; se desactiva sola si la bola se destruye o si termina la partida.

#### Número de vida flotante — quitado (era provisional)

`HealthNumber.lua` reescrito: ya no crea el `BillboardGui` "HealthNumber" (el círculo con el número encima de la bola) — se mantiene la etiqueta con el NOMBRE del jugador (sigue haciendo falta para distinguir bolas del mismo color/arma en pleno combate). `HealthNumber.update()` se deja como no-op seguro en vez de tocar las 6 armas que la llaman en cada golpe (ya no encuentran nada que actualizar, sin necesidad de tocarlas una por una).

#### El "bug de la línea negra" — causa raíz real encontrada

Reproducido en vivo varias veces durante esta sesión (antes solo se había visto en capturas). Descartado por completo, con pruebas directas en playtest: no es inestabilidad física (posición Y perfectamente estable), no es ningún `Wall`/`WallFake` de la arena (comprobadas sus transparencias y posiciones, ninguna cruza el centro), no es un `Trail` huérfano, no es el personaje del jugador (demasiado lejos de la arena). Una búsqueda exhaustiva de cualquier `BasePart` en una columna de 6×60×6 studs centrada en la arena no encontró NADA que explicara la barra — es decir, no era un objeto real del juego en absoluto.

Causa real: `AlignPosition_Y` (la constraint que fija la altura de cada bola, fuerza 10000 solo en el eje Y) tenía su `Position` objetivo fijado a `Vector3.new(0, FixedYPosition, 0)` — el **origen del mundo** en X/Z — porque `MaxAxesForce` solo permite fuerza en Y, así que en teoría el X/Z del objetivo era irrelevante para la física real. Pero **Roblox Studio SÍ dibuja una línea de depuración** desde el attachment de la constraint hasta su posición objetivo mientras se prueba en Play/Run — con el objetivo fijado en el origen del mapa (a cientos de studs de la arena real), esa línea de depuración cruzaba todo el aire de la arena, exactamente la "barra negra" reportada (a veces vertical, a veces horizontal, según la orientación de la cámara fija de espectador en cada partida). **Fix:** el bucle de movimiento de cada bola (que ya corre cada frame) actualiza ahora `alignPosition.Position` al X/Z real de la propia bola en cada tick — la línea de depuración de Studio colapsa a longitud cero porque el objetivo siempre está justo donde está la bola. Confirmado en playtest: la barra desapareció por completo, dejando solo las estelas de color normales (curvas, siguiendo el movimiento real) que sí deben verse.

**Archivos tocados:** `ServerStorage.Classes.BallSystem.HealthNumber` (quitado el número, mantenido el nombre), nuevo `StarterPlayer.StarterPlayerScripts.CombatHUD`, `ServerStorage.Classes.BallSystem.BallFactory` (`AlignPosition_Y.Position` actualizado cada tick al X/Z real), `StarterPlayer.StarterPlayerScripts.LobbyVisuals` (separación entre los 2 `ProximityPrompt` ampliada de 40px a 110px).

**Validado en playtest:** HUD confirmado visible y actualizándose en tiempo real (vida, arma "Espada", "Daño por golpe" subiendo con cada golpe) durante una partida real 1v1 con bot. Confirmado que desaparece con la partida. Confirmado por captura de pantalla que la "barra negra" ya no aparece tras el fix del `AlignPosition`. Sin errores ni warnings en los logs.

***

### 2026-08-01 (bola no se congela al ganar, desync de matchmaking global, rediseño del lobby estilo Fortnite, y un bug de nombrado que rompía la detección de victoria/kills para jugadores reales)

Nuevo `/goal`, cuatro cosas: (1) tras ganar una partida el bot perdedor "seguía peleando" en vez de desaparecer/pararse — pedido explícito: que el ganador se quede quieto en el centro del ring; (2) el contador de "jugadores buscando" del matchmaking global se desincroniza entre clientes (uno ve 2, el otro recién unido ve 0); (3) petición de rediseñar el matchmaking al estilo Fortnite: unirse y poder moverse libremente por el lobby en vez de quedarse atrapado en un menú; (4) revisar una captura ("Error bug de movimiento vertical.png") con dos bolas alineadas verticalmente por una línea fina.

#### 1. Bola no se congela/desaparece al ganar

Diagnóstico: `ArenaManager.EndRound` solo destruye las bolas (`cleanup()`) **8 segundos después** de terminar la partida (mientras dura la pantalla de resultados) — hasta entonces, la bola ganadora seguía con su IA de combate activa (`BallFactory`: busca al rival más cercano y se mueve sola), dando la sensación de "seguir peleando" tras el final. La bola perdedora sí se desintegra al instante (`disintegrateCharacter`, ya existía), pero nada paraba a la ganadora.

**Fix:** nueva función `freezeAndCenterSurvivors()` en `ArenaManager`, llamada al inicio de `EndRound` (antes de construir el payload de resultados): usa el atributo `IsFrozen` que el propio sistema de movimiento de `BallFactory` ya respetaba (lo dejaba en `false` sin ningún sitio que lo pusiera a `true` — mecanismo ya construido pero nunca usado) para parar la IA de búsqueda, y mueve la(s) bola(s) superviviente(s) al centro del ring (guardada ahora en `arenaCenter`, calculada una vez en `StartMatch` a partir del mismo punto que ya usa la cámara). Si hay varios ganadores (equipo completo en 3v3), se reparten en círculo para no quedar superpuestos.

**Validado en playtest:** partida real 1v1 (jugador + bot) hasta el final — confirmado, justo tras terminar (`ArenaManager.IsActive() == false`), la única bola restante tenía `IsFrozen = true`.

#### 2. Bug de nombrado descubierto durante la validación — más grave de lo esperado

Al comprobar el fix anterior con un jugador REAL (no bot), su bola no aparecía donde se esperaba: `workspace:FindFirstChild(player.Name .. "_ball")` (la búsqueda que usa `ArenaManager` en varios sitios: `checkWinCondition` para 3v3, `didPlayerWin`, y `WeaponSystem` para atribuir kills) no la encontraba. Causa raíz: `Customization.SetupSkin` (aplicada a la bola real de cada jugador al crearla, para que muestre su skin elegida) terminaba renombrándola a `"PlayerName_Ball"` (con B **mayúscula**), pisando el nombre correcto que `BallFactory.createBall` ya le había puesto (`"PlayerName_ball"`, b minúscula — la convención real usada en TODO el resto del código, con búsquedas case-sensitive). Los bots nunca pasan por `SetupSkin`, así que nunca se veían afectados — el bug era invisible en pruebas solo-con-bots y solo afectaba a jugadores reales. Esto probablemente ha causado, en silencio, fallos intermitentes de detección de victoria en 3v3 y de atribución de kills en el marcador para cualquier jugador real, sin relación directa con lo pedido en el `/goal` pero descubierto por la propia validación del fix del punto 1. **Fix:** eliminado el renombrado redundante y roto al final de `SetupSkin` (era innecesario también en el caso de la bola de preview de la terminal, que ya se nombra correctamente en `Customization.CreateBall`).

#### 3 y 4. Desync de matchmaking global + rediseño del lobby

**Causa raíz del desync:** `LobbyVisuals.lua` sondeaba `Lobby/GetQueueStatus` cada 3s desde el CLIENTE, y el primer sondeo se disparaba en el mismo instante de unirse a la cola — en paralelo con el propio `GlobalMatchmakingService.AddPlayer`, que escribe en MemoryStore de forma asíncrona (tarda en completarse). Ese primer sondeo podía leer el tamaño de la cola antes de que la propia entrada del jugador estuviera realmente escrita, mostrando "0" en vez del valor real.

**Fix (y aprovechado para el rediseño):** eliminado el sondeo desde el cliente. Ahora el SERVIDOR empuja el tamaño real de la cola (`GlobalMatchmakingService.pushQueueUpdates`, llamada con la misma cadencia que ya usa `Update()` para revisar la cola) a cada jugador local que esté esperando, vía un nuevo remote `Lobby/QueueUpdate`. La primera lectura de cada jugador llega ya consistente porque el push ocurre en un ciclo posterior a que su propia escritura haya tenido tiempo de completarse.

**Rediseño estilo Fortnite:** `LobbyVisuals.lua` reescrito por completo. Antes: acercarse a un hitbox abría automáticamente un menú modal centrado (300x260, con la lista de requisitos y dos botones grandes) que ocultaba el HUD nativo, y alejarse cancelaba la cola automáticamente — quedarse plantado cerca era obligatorio. Ahora: cada hitbox tiene dos `ProximityPrompt` nativos (E = unirse local, F = buscar partida global, mismo patrón ya usado en la terminal de customización), y al unirse aparece solo un indicador pequeño no bloqueante (píldora abajo del todo, con punto pulsante, tiempo en cola, contador de jugadores en modo global, y botón de cancelar) — el jugador puede moverse libremente por el lobby mientras espera, tal como se pidió. Confirmado en playtest: unido a la cola local, teletransportado 30 studs lejos del hitbox, la cola siguió activa y la partida arrancó igualmente.

#### Investigación de la captura "movimiento vertical"

No se encontró ningún bug real de física/movimiento: monitorizada la posición Y de ambas bolas durante una partida completa (25 ticks, 1 por segundo) — se mantuvo perfectamente estable (112.2 constante), confirmando que el `AlignPosition` que fija la altura (fuerza 10000) funciona correctamente sin inestabilidad por colisiones. Explicación más probable, confirmada visualmente en un playtest real: la cámara fija de espectador (`Arena/SetCamera`) mira casi en vertical hacia abajo sobre el ring — capturada una partida real desde esa cámara y se ve exactamente el patrón de la captura del usuario (una línea fina, la estela de color de una bola, cruzando la pantalla) — con esa vista cenital, dos bolas y sus estelas alineándose momentáneamente a lo largo del eje vertical de la pantalla es un efecto normal de la perspectiva, no un bug de movimiento. No se ha tocado nada relacionado, documentado por si se puede reproducir de forma más concreta en el futuro.

**Archivos tocados:** `ServerScriptService.Managers.ArenaManager` (`freezeAndCenterSurvivors`, `arenaCenter`), `ServerStorage.Classes.BallSystem.Customization` (quitado el renombrado roto en `SetupSkin`), `ServerScriptService.Managers.GlobalMatchmakingService` (`pushQueueUpdates`, nuevo require de `CommunicationManager`), `ServerScriptService.GameServer` (nuevo remote `Lobby/QueueUpdate`), `StarterPlayer.StarterPlayerScripts.LobbyVisuals` (reescritura completa: `ProximityPrompt` en vez de menú modal automático, indicador de cola no bloqueante).

**Pendiente:** validar en producción real con 2 dispositivos el matchmaking global (el push de `Lobby/QueueUpdate` no se puede probar con 2 clientes simultáneos reales desde Studio solo-playtest, igual que las limitaciones ya conocidas de `TeleportService`).

***

### 2026-08-01 (cartel de lobby demasiado grande + estado "Blocked" nunca alcanzable — código muerto real)

Nuevo `/goal`: el usuario pegó directamente el fragmento de `LobbyVisuals.lua` que construye el `BillboardGui` de cada hitbox del lobby (el cartel "1v1 / 0 de 2 JUGADORES" flotando sobre cada portal), pidiendo reducir su tamaño (se veía muy grande) y arreglar que nunca llegaba a mostrar los estados "Counting"/"Blocked".

#### Diagnóstico (probado en playtest real, no solo leyendo código)

* **Tamaño:** `BillboardGui.Size` estaba en `300x150` — confirmado excesivo comparado con el resto del HUD. Reducido a `190x95` (`StudsOffset` también reducido de 9 a 7).
* **Estado "Counting":** probado añadiendo un jugador real a la cola vía `LocalLobbyService.AddPlayer` y monitorizando los atributos del hitbox segundo a segundo dentro de una única llamada (para evitar que la latencia de mis propias llamadas de herramienta falseara los tiempos) — **sí funcionaba correctamente**: el cartel muestra "STARTING IN: N" y cuenta atrás bien. No hacía falta arreglar nada aquí; el reporte del usuario sobre "Counting" probablemente venía de pruebas donde nunca llegó a unirse realmente a la cola (o de la confusión con "Blocked", ver abajo).
* **Estado "Blocked": bug real y confirmado.** Nunca existía ningún sitio en todo el código que hiciera `hitbox:SetAttribute("State", "Blocked")` — `LobbyVisuals.lua` comprobaba ese valor pero era papel mojado, código muerto que nunca se podía alcanzar. Además, aunque se hubiera fijado, el hitbox se reseteaba (`LocalLobbyService.ResetLobby`) en el MISMO tick en que se despachaba la partida (`LobbyController.lua`, bucle principal), así que ni con el atributo puesto habría alcanzado a verse — la ventana de tiempo era \~0.

#### Fix

* **`LocalLobbyService.ProcessTick`**: al agotarse el timer y despachar la partida (con o sin relleno de bots), se fija `data.state = "Blocked"` antes de empaquetarla.
* **`LobbyController.lua`**: en vez de resetear el hitbox inmediatamente tras `MatchStarter.Initiate`, se retrasa el reset `BLOCKED_DURATION_S = 6` segundos (`task.delay`), dejando una ventana real donde el cartel muestra "MATCH IN PROGRESS" en rojo.
* **Bug nuevo evitado antes de que existiera:** al retrasar el reset, `ProcessTick` seguía procesando ese hitbox cada segundo mientras estaba "Blocked" — sin cortar el bucle, el timer seguia bajando de 0 y volvía a despachar la MISMA partida (con jugadores que ya se habían teletransportado) una vez por segundo durante toda la ventana de 6s. Añadido un `continue` al principio del bucle de `ProcessTick` que salta cualquier hitbox ya en estado "Blocked". También `LocalLobbyService.AddPlayer` rechaza ahora nuevas uniones mientras el hitbox está "Blocked" (antes solo comprobaba `MaxPlayers`, y como los jugadores despachados se limpian de `data.players` en cuanto se teletransportan — vía `Players.PlayerRemoving` — un jugador nuevo podía colarse en la cola durante la ventana bloqueada y luego ser expulsado sin aviso al llegar el reset).

#### Validación

Playtest real: se unió un jugador real a la cola local de "1v1" y se registró el atributo `State` del hitbox segundo a segundo dentro de una sola llamada atómica (18 iteraciones de 1s). Secuencia confirmada exacta: `Waiting` → `Counting` (cuenta atrás 10→1) → `Blocked` en el instante exacto en que `TimeLeft` llega a 0, **manteniéndose en `Blocked` de forma continua durante los 6 segundos completos** antes de volver a `Waiting`. Confirmado por logs que `MatchStarter.Initiate` (y el print "Tiempo fuera. Rellenando con bots...") solo se dispara **una vez** por ciclo, no repetidamente durante la ventana bloqueada. Confirmado por lectura directa del `BillboardGui` en el cliente que el tamaño real es ahora 190×95 (antes 300×150). Sin errores ni warnings en los logs en ningún momento de la prueba.

**Archivos tocados:** `ServerScriptService.Controllers.LobbyController.LocalLobbyService` (estado "Blocked" real, guard `continue`, `AddPlayer` rechaza durante "Blocked"), `ServerScriptService.Controllers.LobbyController` (reset retrasado, nueva constante `BLOCKED_DURATION_S`), `StarterPlayer.StarterPlayerScripts.LobbyVisuals` (tamaño del `BillboardGui` reducido).


---

# Agent Instructions
This documentation is published with GitBook. GitBook is the documentation platform designed so that both humans and AI agents can read, navigate, and reason over technical content effectively. Learn more at gitbook.com.

## Querying This Documentation
If you need additional information that is not directly available in this page, you can query the documentation dynamically by asking a question.

Perform an HTTP GET request on the current page URL with the `ask` query parameter, and the optional `goal` query parameter:

```
GET https://claude.fractalgamestudios.es/bolas/game-info/customizacion-robux-gratis-y-hud-de-combate-parte-1.md?ask=<question>&goal=<endgoal>
```

`ask` is the immediate question: it should be specific, self-contained, and written in natural language.
`goal` is optional and describes the broader end goal you are ultimately trying to accomplish on behalf of the user. GitBook uses it to tailor the answer towards what is most useful for that goal.

The response will contain a direct answer to the question and relevant excerpts and sources from the documentation.

Use this mechanism when the answer is not explicitly present in the current page, you need clarification or additional context, or you want to retrieve related documentation sections.
