Game — Character / Loadout Select Screen
A hero-shooter style character and loadout select screen with a neon sci-fi HUD aesthetic: a roster grid of selectable tiles with CSS gradient portraits, role icons, and locked states; a featured panel showing the hovered or selected Vanguard with epithet, difficulty pips, ability list, and animated skewed stat bars; a three-slot loadout strip with cycling weapons and perks; plus a Lock In button with cancellable countdown, party status dots, and a pulsing phase timer that auto-locks on expiry.
MCP
Code
:root {
--bg: #0a0b10;
--bg-2: #12131c;
--panel: #171926;
--panel-2: #1f2233;
--text: #e7e9f3;
--muted: #9aa0bf;
--line: rgba(231, 233, 243, 0.1);
--line-2: rgba(231, 233, 243, 0.18);
--accent: #00e5ff;
--accent-2: #7c4dff;
--accent-3: #ff3d71;
--success: #36e27a;
--warn: #ffc857;
--danger: #ff4d4d;
--glow: 0 0 18px rgba(0, 229, 255, 0.45);
--r-sm: 6px;
--r-md: 10px;
--r-lg: 16px;
--font-display: "Orbitron", sans-serif;
--font-body: "Inter", sans-serif;
}
* { box-sizing: border-box; }
body {
margin: 0;
background:
radial-gradient(1100px 520px at 78% -10%, rgba(124, 77, 255, 0.14), transparent 60%),
radial-gradient(900px 480px at 8% 110%, rgba(0, 229, 255, 0.1), transparent 60%),
var(--bg);
color: var(--text);
font-family: var(--font-body);
line-height: 1.5;
-webkit-font-smoothing: antialiased;
min-height: 100vh;
}
button { font: inherit; color: inherit; cursor: pointer; }
:focus-visible {
outline: 2px solid var(--accent);
outline-offset: 2px;
border-radius: var(--r-sm);
}
.select-screen {
max-width: 1180px;
margin: 0 auto;
padding: 20px clamp(12px, 3vw, 28px) 28px;
display: flex;
flex-direction: column;
gap: 18px;
}
/* ===== Top bar ===== */
.topbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
flex-wrap: wrap;
padding: 12px 16px;
background: linear-gradient(180deg, var(--panel) 0%, var(--bg-2) 100%);
border: 1px solid var(--line);
border-radius: var(--r-lg);
}
.topbar__brand { display: flex; align-items: center; gap: 12px; }
.topbar__logo {
font-family: var(--font-display);
font-weight: 900;
font-size: 0.95rem;
letter-spacing: 0.08em;
color: #051018;
background: linear-gradient(135deg, var(--accent), var(--accent-2));
width: 42px;
height: 42px;
display: grid;
place-items: center;
clip-path: polygon(20% 0, 100% 0, 100% 80%, 80% 100%, 0 100%, 0 20%);
box-shadow: var(--glow);
}
.topbar__titles { display: flex; flex-direction: column; }
.topbar__game {
font-family: var(--font-display);
font-weight: 700;
font-size: 1rem;
letter-spacing: 0.14em;
text-transform: uppercase;
}
.topbar__mode { font-size: 0.76rem; color: var(--muted); }
.topbar__phase {
display: flex;
align-items: center;
gap: 12px;
}
.topbar__phase-label {
font-size: 0.74rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--muted);
}
.topbar__timer {
font-family: var(--font-display);
font-weight: 700;
font-size: 1.45rem;
color: var(--accent);
text-shadow: 0 0 12px rgba(0, 229, 255, 0.55);
min-width: 64px;
text-align: center;
padding: 4px 12px;
border: 1px solid var(--line-2);
background: var(--bg-2);
clip-path: polygon(12px 0, 100% 0, 100% calc(100% - 12px), calc(100% - 12px) 100%, 0 100%, 0 12px);
}
.topbar__timer small { font-size: 0.7rem; color: var(--muted); margin-left: 2px; }
.topbar__timer.is-urgent { color: var(--danger); text-shadow: 0 0 12px rgba(255, 77, 77, 0.6); animation: pulse 0.9s ease-in-out infinite; }
.topbar__party { display: flex; gap: 8px; align-items: center; }
.party-dot {
width: 12px;
height: 12px;
border-radius: 50%;
background: var(--panel-2);
border: 1px solid var(--line-2);
}
.party-dot--locked { background: var(--success); border-color: var(--success); box-shadow: 0 0 8px rgba(54, 226, 122, 0.6); }
.party-dot--you { background: var(--accent); border-color: var(--accent); box-shadow: var(--glow); animation: pulse 1.4s ease-in-out infinite; }
@keyframes pulse {
0%, 100% { opacity: 1; }
50% { opacity: 0.45; }
}
/* ===== Layout ===== */
.layout {
display: grid;
grid-template-columns: minmax(280px, 380px) 1fr;
gap: 18px;
align-items: stretch;
}
/* ===== Roster ===== */
.roster {
background: linear-gradient(180deg, var(--panel) 0%, var(--bg-2) 100%);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 16px;
display: flex;
flex-direction: column;
gap: 14px;
}
.roster__head {
display: flex;
align-items: baseline;
justify-content: space-between;
}
.roster__title {
margin: 0;
font-family: var(--font-display);
font-size: 0.92rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.roster__count { font-size: 0.74rem; color: var(--muted); }
.roster__grid {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.tile {
position: relative;
aspect-ratio: 3 / 3.6;
border: 1px solid var(--line);
border-radius: var(--r-md);
background: var(--panel-2);
padding: 0;
overflow: hidden;
display: flex;
flex-direction: column;
justify-content: flex-end;
transition: transform 0.16s ease, border-color 0.16s ease, box-shadow 0.16s ease;
clip-path: polygon(0 0, calc(100% - 10px) 0, 100% 10px, 100% 100%, 10px 100%, 0 calc(100% - 10px));
}
.tile__portrait {
position: absolute;
inset: 0;
display: grid;
place-items: center;
}
.tile__sigil {
font-family: var(--font-display);
font-weight: 900;
font-size: 1.5rem;
letter-spacing: 0.05em;
color: rgba(231, 233, 243, 0.85);
text-shadow: 0 2px 12px rgba(0, 0, 0, 0.6);
}
.tile__meta {
position: relative;
z-index: 1;
padding: 6px 8px 7px;
background: linear-gradient(180deg, transparent, rgba(5, 6, 10, 0.88) 40%);
display: flex;
flex-direction: column;
gap: 2px;
text-align: left;
}
.tile__name {
font-family: var(--font-display);
font-size: 0.66rem;
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tile__role {
font-size: 0.62rem;
color: var(--muted);
display: flex;
align-items: center;
gap: 4px;
}
.role-ico {
width: 8px;
height: 8px;
display: inline-block;
flex: none;
}
.role-ico--assault, .legend-swatch--assault { background: var(--accent-3); clip-path: polygon(50% 0, 100% 100%, 0 100%); }
.role-ico--support, .legend-swatch--support { background: var(--success); clip-path: polygon(40% 0, 60% 0, 60% 40%, 100% 40%, 100% 60%, 60% 60%, 60% 100%, 40% 100%, 40% 60%, 0 60%, 0 40%, 40% 40%); }
.role-ico--recon, .legend-swatch--recon { background: var(--accent); border-radius: 50%; }
.role-ico--tank, .legend-swatch--tank { background: var(--warn); clip-path: polygon(0 0, 100% 0, 100% 60%, 50% 100%, 0 60%); }
.tile:hover:not(.tile--locked),
.tile.is-hovered:not(.tile--locked) {
transform: translateY(-3px);
border-color: var(--line-2);
box-shadow: 0 0 0 1px rgba(0, 229, 255, 0.25), 0 8px 22px rgba(0, 0, 0, 0.45);
}
.tile.is-selected {
border-color: var(--accent);
box-shadow: var(--glow), inset 0 0 24px rgba(0, 229, 255, 0.12);
}
.tile.is-selected::after {
content: "";
position: absolute;
inset: 0;
border: 1px solid rgba(0, 229, 255, 0.5);
pointer-events: none;
clip-path: inherit;
}
.tile--locked {
cursor: not-allowed;
filter: saturate(0.25) brightness(0.65);
}
.tile__lock {
position: absolute;
top: 6px;
right: 8px;
z-index: 1;
font-size: 0.72rem;
color: var(--warn);
text-shadow: 0 0 8px rgba(255, 200, 87, 0.5);
}
.roster__legend {
display: flex;
flex-wrap: wrap;
gap: 10px 14px;
font-size: 0.68rem;
color: var(--muted);
border-top: 1px solid var(--line);
padding-top: 12px;
}
.roster__legend span { display: inline-flex; align-items: center; gap: 6px; }
.legend-swatch { width: 9px; height: 9px; display: inline-block; }
/* ===== Featured panel ===== */
.featured {
background: linear-gradient(160deg, var(--panel) 0%, var(--bg-2) 70%);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: clamp(16px, 2.5vw, 26px);
display: flex;
flex-direction: column;
gap: 16px;
position: relative;
overflow: hidden;
}
.featured::before {
content: "";
position: absolute;
top: 0;
left: 0;
right: 0;
height: 2px;
background: linear-gradient(90deg, transparent, var(--accent), var(--accent-2), transparent);
opacity: 0.7;
}
.featured__stage {
display: flex;
gap: 20px;
align-items: center;
flex-wrap: wrap;
}
.featured__portrait {
position: relative;
width: 132px;
height: 152px;
flex: none;
display: grid;
place-items: center;
background: linear-gradient(150deg, var(--accent-2), var(--accent-3));
clip-path: polygon(0 0, calc(100% - 18px) 0, 100% 18px, 100% 100%, 18px 100%, 0 calc(100% - 18px));
box-shadow: 0 0 28px rgba(124, 77, 255, 0.35);
transition: background 0.3s ease;
}
.featured__sigil {
font-family: var(--font-display);
font-weight: 900;
font-size: 2.6rem;
color: rgba(10, 11, 16, 0.85);
text-shadow: 0 1px 0 rgba(255, 255, 255, 0.25);
}
.featured__scanline {
position: absolute;
inset: 0;
background: repeating-linear-gradient(
180deg,
rgba(10, 11, 16, 0.16) 0 2px,
transparent 2px 5px
);
pointer-events: none;
}
.featured__id { display: flex; flex-direction: column; gap: 4px; min-width: 0; }
.featured__role {
font-size: 0.7rem;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.2em;
color: var(--accent);
}
.featured__name {
margin: 0;
font-family: var(--font-display);
font-weight: 900;
font-size: clamp(1.7rem, 4vw, 2.5rem);
letter-spacing: 0.06em;
text-transform: uppercase;
line-height: 1.1;
}
.featured__name.is-swapping { animation: glitch-in 0.32s ease; }
@keyframes glitch-in {
0% { opacity: 0; transform: translateX(-8px) skewX(-6deg); filter: blur(2px); }
60% { opacity: 1; transform: translateX(2px) skewX(2deg); filter: blur(0); }
100% { transform: none; }
}
.featured__epithet {
margin: 0;
font-size: 0.86rem;
color: var(--muted);
font-style: italic;
}
.featured__difficulty {
display: flex;
align-items: center;
gap: 10px;
margin-top: 6px;
}
.featured__diff-label {
font-size: 0.68rem;
text-transform: uppercase;
letter-spacing: 0.14em;
color: var(--muted);
}
.diff-pips { display: flex; gap: 5px; }
.diff-pip {
width: 16px;
height: 7px;
background: var(--panel-2);
border: 1px solid var(--line-2);
transform: skewX(-18deg);
transition: background 0.2s ease, box-shadow 0.2s ease;
}
.diff-pip.is-on {
background: var(--warn);
border-color: var(--warn);
box-shadow: 0 0 8px rgba(255, 200, 87, 0.55);
}
.featured__lore {
margin: 0;
max-width: 60ch;
color: var(--muted);
font-size: 0.9rem;
border-left: 2px solid var(--accent-2);
padding-left: 12px;
}
.featured__columns {
display: grid;
grid-template-columns: 1.1fr 1fr;
gap: 20px;
}
.panel-heading {
margin: 0 0 10px;
font-family: var(--font-display);
font-size: 0.78rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
color: var(--text);
}
/* Abilities */
.abilities__list {
margin: 0;
padding: 0;
list-style: none;
display: flex;
flex-direction: column;
gap: 8px;
}
.ability {
display: flex;
gap: 10px;
align-items: flex-start;
background: rgba(231, 233, 243, 0.03);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 8px 10px;
transition: border-color 0.16s ease, background 0.16s ease;
}
.ability:hover { border-color: var(--line-2); background: rgba(0, 229, 255, 0.05); }
.ability__key {
flex: none;
font-family: var(--font-display);
font-weight: 700;
font-size: 0.72rem;
width: 26px;
height: 26px;
display: grid;
place-items: center;
color: var(--accent);
border: 1px solid var(--line-2);
background: var(--bg-2);
clip-path: polygon(6px 0, 100% 0, 100% calc(100% - 6px), calc(100% - 6px) 100%, 0 100%, 0 6px);
}
.ability--ult .ability__key {
color: #0a0b10;
background: var(--accent-3);
border-color: var(--accent-3);
box-shadow: 0 0 12px rgba(255, 61, 113, 0.5);
}
.ability__body { min-width: 0; }
.ability__name { display: block; font-weight: 700; font-size: 0.82rem; }
.ability__desc { display: block; font-size: 0.74rem; color: var(--muted); }
/* Stats */
.stats__rows { display: flex; flex-direction: column; gap: 11px; }
.stat { display: flex; flex-direction: column; gap: 4px; }
.stat__top {
display: flex;
justify-content: space-between;
font-size: 0.72rem;
text-transform: uppercase;
letter-spacing: 0.1em;
}
.stat__label { color: var(--muted); }
.stat__value {
font-family: var(--font-display);
font-weight: 700;
color: var(--accent);
}
.stat__track {
height: 8px;
background: var(--bg-2);
border: 1px solid var(--line);
overflow: hidden;
transform: skewX(-14deg);
}
.stat__fill {
height: 100%;
width: 0;
background: linear-gradient(90deg, var(--accent-2), var(--accent));
box-shadow: 0 0 10px rgba(0, 229, 255, 0.45);
transition: width 0.55s cubic-bezier(0.22, 1, 0.36, 1);
}
/* ===== Dock: loadout + ready ===== */
.dock {
display: grid;
grid-template-columns: 1fr auto;
gap: 18px;
align-items: stretch;
}
.loadout {
background: linear-gradient(180deg, var(--panel) 0%, var(--bg-2) 100%);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 14px 16px;
display: flex;
flex-direction: column;
gap: 10px;
}
.loadout__title {
margin: 0;
font-family: var(--font-display);
font-size: 0.76rem;
font-weight: 700;
letter-spacing: 0.18em;
text-transform: uppercase;
}
.loadout__slots {
display: grid;
grid-template-columns: repeat(3, 1fr);
gap: 10px;
}
.slot {
display: grid;
grid-template-columns: auto 1fr auto;
grid-template-areas:
"kind kind cycle"
"icon name cycle";
align-items: center;
column-gap: 8px;
text-align: left;
background: var(--panel-2);
border: 1px solid var(--line);
border-radius: var(--r-md);
padding: 8px 10px;
transition: border-color 0.16s ease, box-shadow 0.16s ease, transform 0.16s ease;
clip-path: polygon(0 0, calc(100% - 8px) 0, 100% 8px, 100% 100%, 8px 100%, 0 calc(100% - 8px));
}
.slot:hover { border-color: var(--accent); box-shadow: inset 0 0 14px rgba(0, 229, 255, 0.1); transform: translateY(-1px); }
.slot:active { transform: translateY(0); }
.slot__kind {
grid-area: kind;
font-size: 0.62rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--muted);
}
.slot__icon { grid-area: icon; color: var(--accent-2); font-size: 1rem; }
.slot__name { grid-area: name; font-weight: 700; font-size: 0.8rem; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.slot__cycle { grid-area: cycle; color: var(--muted); font-size: 0.85rem; transition: transform 0.25s ease, color 0.16s ease; }
.slot:hover .slot__cycle { color: var(--accent); }
.slot.is-cycling .slot__cycle { transform: rotate(180deg); }
.slot.is-cycling .slot__name { animation: glitch-in 0.28s ease; }
.slot[disabled] { opacity: 0.55; cursor: not-allowed; transform: none; box-shadow: none; }
/* Ready */
.ready-zone {
display: flex;
align-items: center;
gap: 16px;
background: linear-gradient(180deg, var(--panel) 0%, var(--bg-2) 100%);
border: 1px solid var(--line);
border-radius: var(--r-lg);
padding: 14px 18px;
}
.ready-zone__summary { display: flex; flex-direction: column; gap: 2px; }
.ready-zone__label {
font-size: 0.64rem;
text-transform: uppercase;
letter-spacing: 0.16em;
color: var(--muted);
}
.ready-zone__pick {
font-family: var(--font-display);
font-weight: 700;
font-size: 0.86rem;
letter-spacing: 0.06em;
white-space: nowrap;
}
.ready-btn {
position: relative;
font-family: var(--font-display);
font-weight: 900;
font-size: 1rem;
letter-spacing: 0.18em;
text-transform: uppercase;
color: #051018;
background: linear-gradient(135deg, var(--accent) 0%, #4ddfff 100%);
border: none;
padding: 16px 34px;
clip-path: polygon(14px 0, 100% 0, 100% calc(100% - 14px), calc(100% - 14px) 100%, 0 100%, 0 14px);
box-shadow: var(--glow);
transition: transform 0.14s ease, box-shadow 0.14s ease, filter 0.14s ease;
}
.ready-btn:hover { transform: translateY(-2px); box-shadow: 0 0 26px rgba(0, 229, 255, 0.65); filter: brightness(1.05); }
.ready-btn:active { transform: translateY(0); }
.ready-btn.is-counting {
background: linear-gradient(135deg, var(--warn) 0%, #ffd97a 100%);
box-shadow: 0 0 22px rgba(255, 200, 87, 0.55);
animation: pulse 0.8s ease-in-out infinite;
}
.ready-btn.is-locked {
background: linear-gradient(135deg, var(--success) 0%, #6bf0a0 100%);
box-shadow: 0 0 22px rgba(54, 226, 122, 0.55);
cursor: default;
animation: none;
}
/* ===== Toast ===== */
.toast {
position: fixed;
left: 50%;
bottom: 26px;
transform: translate(-50%, 16px);
background: var(--panel-2);
border: 1px solid var(--accent);
color: var(--text);
font-size: 0.82rem;
font-weight: 600;
padding: 10px 18px;
clip-path: polygon(10px 0, 100% 0, 100% calc(100% - 10px), calc(100% - 10px) 100%, 0 100%, 0 10px);
box-shadow: var(--glow);
opacity: 0;
pointer-events: none;
transition: opacity 0.22s ease, transform 0.22s ease;
z-index: 50;
max-width: min(92vw, 420px);
text-align: center;
}
.toast.is-visible { opacity: 1; transform: translate(-50%, 0); }
/* ===== Responsive ===== */
@media (max-width: 880px) {
.layout { grid-template-columns: 1fr; }
.roster__grid { grid-template-columns: repeat(4, 1fr); }
.featured__columns { grid-template-columns: 1fr; }
.dock { grid-template-columns: 1fr; }
.ready-zone { justify-content: space-between; }
}
@media (max-width: 520px) {
.select-screen { padding: 12px 10px 20px; gap: 12px; }
.topbar { flex-direction: column; align-items: flex-start; gap: 10px; }
.topbar__phase { width: 100%; justify-content: space-between; }
.roster__grid { grid-template-columns: repeat(3, 1fr); }
.loadout__slots { grid-template-columns: 1fr; }
.featured__stage { flex-direction: column; align-items: flex-start; }
.ready-zone { flex-direction: column; align-items: stretch; text-align: center; }
.ready-zone__pick { white-space: normal; }
.ready-btn { width: 100%; padding: 14px 18px; }
}/* Ashen Vanguard — character / loadout select screen (fictional demo data) */
const ROSTER = [
{
id: "vyre", name: "Vyre", epithet: "The Cinder Blade", role: "Assault",
sigil: "VY", difficulty: 2, locked: false,
gradient: ["#7c4dff", "#ff3d71"],
lore: "Forged in the ash fields of Old Meridia, Vyre carves through frontlines with twin plasma edges and a grudge that outlived three wars.",
abilities: [
{ key: "Q", name: "Ember Dash", desc: "Blink forward, igniting enemies in your path." },
{ key: "E", name: "Twin Arc", desc: "Cross-slash that shreds shields at close range." },
{ key: "R", name: "Cinderstorm", desc: "ULT — Whirlwind of burning blades for 6s.", ult: true },
],
stats: { Damage: 86, Mobility: 78, Survival: 54, Utility: 40 },
},
{
id: "okta", name: "Okta-9", epithet: "Siege Chassis", role: "Bulwark",
sigil: "O9", difficulty: 1, locked: false,
gradient: ["#ffc857", "#ff3d71"],
lore: "A decommissioned siege automaton reactivated by the Vanguard. Okta-9 holds the line — or becomes it.",
abilities: [
{ key: "Q", name: "Aegis Wall", desc: "Deploy a 4m hardlight barrier." },
{ key: "E", name: "Magnet Pull", desc: "Drag a target into melee range." },
{ key: "R", name: "Fortress Mode", desc: "ULT — Anchor and gain 60% damage reduction.", ult: true },
],
stats: { Damage: 48, Mobility: 30, Survival: 95, Utility: 62 },
},
{
id: "lyss", name: "Lyssira", epithet: "Choir of Static", role: "Support",
sigil: "LY", difficulty: 2, locked: false,
gradient: ["#36e27a", "#00e5ff"],
lore: "Her resonance hymns knit flesh and circuitry alike. Squads that march with Lyssira rarely march home alone.",
abilities: [
{ key: "Q", name: "Mend Chord", desc: "Heal beam that arcs between two allies." },
{ key: "E", name: "Dissonance", desc: "Silence enemy abilities in a small zone." },
{ key: "R", name: "Crescendo", desc: "ULT — Mass overheal and cleanse for the squad.", ult: true },
],
stats: { Damage: 38, Mobility: 55, Survival: 60, Utility: 94 },
},
{
id: "hex", name: "Hexen", epithet: "Null Protocol", role: "Recon",
sigil: "HX", difficulty: 3, locked: false,
gradient: ["#00e5ff", "#7c4dff"],
lore: "A ghost in every network. Hexen sees the battlefield three seconds before it happens — and bets lives on it.",
abilities: [
{ key: "Q", name: "Wire Tap", desc: "Reveal enemies through walls for 4s." },
{ key: "E", name: "Phase Step", desc: "Brief intangibility; drops aggro." },
{ key: "R", name: "Blackout", desc: "ULT — Disable enemy HUDs and minimap.", ult: true },
],
stats: { Damage: 64, Mobility: 90, Survival: 42, Utility: 80 },
},
{
id: "brakk", name: "Brakkar", epithet: "The Last Anvil", role: "Bulwark",
sigil: "BR", difficulty: 1, locked: false,
gradient: ["#ffc857", "#7c4dff"],
lore: "The final smith of the Hollow Reign forges, Brakkar wears his masterpiece into battle: a mountain of reactive plate.",
abilities: [
{ key: "Q", name: "Shockslam", desc: "Ground pound that staggers in a cone." },
{ key: "E", name: "Reforge", desc: "Convert incoming damage into armor." },
{ key: "R", name: "Anvilfall", desc: "ULT — Leap and crater the target zone.", ult: true },
],
stats: { Damage: 58, Mobility: 35, Survival: 92, Utility: 50 },
},
{
id: "nyx", name: "Nyxa", epithet: "Veil Dancer", role: "Recon",
sigil: "NX", difficulty: 3, locked: false,
gradient: ["#ff3d71", "#7c4dff"],
lore: "Nobody has seen Nyxa's face and lived to file the report. The Veil moves where she wills it.",
abilities: [
{ key: "Q", name: "Smoke Veil", desc: "Cloud that blinds and cloaks allies." },
{ key: "E", name: "Shadow Hook", desc: "Grapple to any surface within 18m." },
{ key: "R", name: "Eclipse", desc: "ULT — Squad-wide invisibility for 5s.", ult: true },
],
stats: { Damage: 70, Mobility: 95, Survival: 38, Utility: 66 },
},
{
id: "juno", name: "Juno Vex", epithet: "Ordnance Queen", role: "Assault",
sigil: "JV", difficulty: 2, locked: false,
gradient: ["#ff3d71", "#ffc857"],
lore: "Ex-corporate demolitions, now freelance chaos. Juno believes every problem is a structural-integrity problem.",
abilities: [
{ key: "Q", name: "Cluster Toss", desc: "Lob a splitting grenade cluster." },
{ key: "E", name: "Breach Charge", desc: "Stick a charge that blows cover open." },
{ key: "R", name: "Danger Close", desc: "ULT — Call an artillery line strike.", ult: true },
],
stats: { Damage: 92, Mobility: 50, Survival: 48, Utility: 58 },
},
{
id: "sol", name: "Solenne", epithet: "Dawn Warden", role: "Support",
sigil: "SL", difficulty: 1, locked: false,
gradient: ["#36e27a", "#ffc857"],
lore: "A warden-priest of the Sunken Cathedral who traded relics for medkits. Her light is literal and weaponized.",
abilities: [
{ key: "Q", name: "Solar Ward", desc: "Zone that heals allies and burns foes." },
{ key: "E", name: "Lifeline", desc: "Instantly revive at range, once per life." },
{ key: "R", name: "Daybreak", desc: "ULT — Blinding flash; resets cooldowns.", ult: true },
],
stats: { Damage: 42, Mobility: 46, Survival: 70, Utility: 90 },
},
{
id: "rook", name: "Rooke", epithet: "Iron Stray", role: "Assault",
sigil: "RK", difficulty: 1, locked: false,
gradient: ["#00e5ff", "#36e27a"],
lore: "A drifting gun-for-hire from the Neon Drift circuits. Reliable trigger, unreliable loyalties — until now.",
abilities: [
{ key: "Q", name: "Steady Aim", desc: "Next 3 shots gain perfect accuracy." },
{ key: "E", name: "Combat Slide", desc: "Slide-reload that breaks target lock." },
{ key: "R", name: "Dead Eye", desc: "ULT — Auto-mark and pierce all targets.", ult: true },
],
stats: { Damage: 80, Mobility: 68, Survival: 56, Utility: 36 },
},
{
id: "mor", name: "Morvain", epithet: "Grave Tide", role: "Bulwark",
sigil: "MV", difficulty: 3, locked: true, unlock: "Reach Account Level 20",
gradient: ["#7c4dff", "#12131c"],
lore: "Locked — Reach Account Level 20 to recruit Morvain.",
abilities: [], stats: { Damage: 60, Mobility: 40, Survival: 88, Utility: 70 },
},
{
id: "cyra", name: "Cyra-Null", epithet: "Echo of Nullforge", role: "Recon",
sigil: "CN", difficulty: 3, locked: true, unlock: "Complete the Nullforge event",
gradient: ["#00e5ff", "#12131c"],
lore: "Locked — Complete the Nullforge event to recruit Cyra-Null.",
abilities: [], stats: { Damage: 72, Mobility: 88, Survival: 40, Utility: 76 },
},
{
id: "ashk", name: "Ashkar", epithet: "Hollow King", role: "Assault",
sigil: "AK", difficulty: 3, locked: true, unlock: "Own the Hollow Reign expansion",
gradient: ["#ff3d71", "#12131c"],
lore: "Locked — Own the Hollow Reign expansion to recruit Ashkar.",
abilities: [], stats: { Damage: 96, Mobility: 60, Survival: 50, Utility: 44 },
},
];
const LOADOUT_OPTIONS = {
primary: ["Helix Carbine", "Maul SMG-7", "Longshot DMR", "Pyre Scattergun"],
secondary: ["Arc Pistol", "Stinger Machine Pistol", "Riot Blade", "Flux Repeater"],
perk: ["Adrenal Surge", "Ghost Plating", "Scavenger Core", "Overclock Module"],
};
const ROLE_CLASS = {
Assault: "assault",
Support: "support",
Recon: "recon",
Bulwark: "tank",
};
/* ---------- helpers ---------- */
const $ = (sel) => document.querySelector(sel);
let toastTimer = null;
function toast(msg) {
const el = $("#toast");
el.textContent = msg;
el.classList.add("is-visible");
clearTimeout(toastTimer);
toastTimer = setTimeout(() => el.classList.remove("is-visible"), 2400);
}
/* ---------- state ---------- */
let selectedId = "vyre";
let lockedIn = false;
let countdownTimer = null;
const loadoutIndex = { primary: 0, secondary: 0, perk: 0 };
/* ---------- roster grid ---------- */
const grid = $("#rosterGrid");
function buildRoster() {
ROSTER.forEach((c) => {
const tile = document.createElement("button");
tile.type = "button";
tile.className = "tile" + (c.locked ? " tile--locked" : "");
tile.dataset.id = c.id;
tile.setAttribute("role", "option");
tile.setAttribute("aria-selected", String(c.id === selectedId));
tile.setAttribute(
"aria-label",
c.locked ? `${c.name}, ${c.role}, locked — ${c.unlock}` : `${c.name}, ${c.role}`
);
tile.innerHTML = `
<span class="tile__portrait" style="background:linear-gradient(160deg, ${c.gradient[0]}, ${c.gradient[1]})">
<span class="tile__sigil">${c.sigil}</span>
</span>
${c.locked ? '<span class="tile__lock" aria-hidden="true">🔒</span>' : ""}
<span class="tile__meta">
<span class="tile__name">${c.name}</span>
<span class="tile__role"><i class="role-ico role-ico--${ROLE_CLASS[c.role]}"></i>${c.role}</span>
</span>`;
tile.addEventListener("click", () => {
if (lockedIn) return toast("Already locked in — deploying soon.");
if (c.locked) return toast(`${c.name} is locked: ${c.unlock}.`);
selectCharacter(c.id);
});
tile.addEventListener("mouseenter", () => {
if (!c.locked && !lockedIn && c.id !== selectedId) renderFeatured(c, true);
});
tile.addEventListener("mouseleave", () => {
if (!lockedIn) renderFeatured(getSelected(), false);
});
grid.appendChild(tile);
});
const unlocked = ROSTER.filter((c) => !c.locked).length;
$("#rosterCount").textContent = `${unlocked} / ${ROSTER.length} unlocked`;
}
function getSelected() {
return ROSTER.find((c) => c.id === selectedId);
}
function selectCharacter(id) {
selectedId = id;
grid.querySelectorAll(".tile").forEach((t) => {
const isSel = t.dataset.id === id;
t.classList.toggle("is-selected", isSel);
t.setAttribute("aria-selected", String(isSel));
});
const c = getSelected();
renderFeatured(c, false);
$("#readySummary").textContent = `${c.name} — ${c.role}`;
toast(`${c.name} selected.`);
}
/* ---------- featured panel ---------- */
function renderFeatured(c, isPreview) {
const portrait = $("#featuredPortrait");
portrait.style.background = `linear-gradient(150deg, ${c.gradient[0]}, ${c.gradient[1]})`;
$("#featuredSigil").textContent = c.sigil;
const nameEl = $("#featuredName");
nameEl.textContent = c.name;
nameEl.classList.remove("is-swapping");
void nameEl.offsetWidth; /* restart animation */
nameEl.classList.add("is-swapping");
$("#featuredRole").textContent = c.role + (isPreview ? " — preview" : "");
$("#featuredEpithet").textContent = c.epithet;
$("#featuredLore").textContent = c.lore;
// difficulty pips
const pips = $("#diffPips");
pips.setAttribute("aria-label", `Difficulty ${c.difficulty} of 3`);
pips.querySelectorAll(".diff-pip").forEach((p, i) => {
p.classList.toggle("is-on", i < c.difficulty);
});
// abilities
const list = $("#abilityList");
list.innerHTML = "";
if (c.abilities.length === 0) {
const li = document.createElement("li");
li.className = "ability";
li.innerHTML = `<span class="ability__key">?</span>
<span class="ability__body">
<span class="ability__name">Classified</span>
<span class="ability__desc">Unlock this Vanguard to view their kit.</span>
</span>`;
list.appendChild(li);
} else {
c.abilities.forEach((a) => {
const li = document.createElement("li");
li.className = "ability" + (a.ult ? " ability--ult" : "");
li.innerHTML = `<span class="ability__key">${a.key}</span>
<span class="ability__body">
<span class="ability__name">${a.name}</span>
<span class="ability__desc">${a.desc}</span>
</span>`;
list.appendChild(li);
});
}
renderStats(c.stats);
}
function renderStats(stats) {
const wrap = $("#statRows");
wrap.innerHTML = "";
Object.entries(stats).forEach(([label, value]) => {
const row = document.createElement("div");
row.className = "stat";
row.innerHTML = `
<span class="stat__top">
<span class="stat__label">${label}</span>
<span class="stat__value">${value}</span>
</span>
<span class="stat__track"><span class="stat__fill"></span></span>`;
wrap.appendChild(row);
// animate fill on next frame
requestAnimationFrame(() =>
requestAnimationFrame(() => {
row.querySelector(".stat__fill").style.width = value + "%";
})
);
});
}
/* ---------- loadout cycling ---------- */
document.querySelectorAll(".slot").forEach((slot) => {
slot.addEventListener("click", () => {
if (lockedIn) return toast("Loadout locked.");
const kind = slot.dataset.slot;
const opts = LOADOUT_OPTIONS[kind];
loadoutIndex[kind] = (loadoutIndex[kind] + 1) % opts.length;
const next = opts[loadoutIndex[kind]];
slot.querySelector("[data-slot-name]").textContent = next;
slot.classList.remove("is-cycling");
void slot.offsetWidth;
slot.classList.add("is-cycling");
toast(`${kind.charAt(0).toUpperCase() + kind.slice(1)} equipped: ${next}.`);
});
});
/* ---------- lock-in with countdown ---------- */
const readyBtn = $("#readyBtn");
const readyText = readyBtn.querySelector(".ready-btn__text");
function lockIn() {
if (lockedIn) return;
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
}
lockedIn = true;
readyBtn.classList.remove("is-counting");
readyBtn.classList.add("is-locked");
readyBtn.setAttribute("aria-disabled", "true");
readyText.textContent = "Locked ✓";
document.querySelectorAll(".slot").forEach((s) => (s.disabled = true));
const c = getSelected();
toast(`${c.name} locked in. Deploying to Cinder Bastion…`);
}
readyBtn.addEventListener("click", () => {
if (lockedIn) return;
// cancel a running countdown
if (countdownTimer) {
clearInterval(countdownTimer);
countdownTimer = null;
readyBtn.classList.remove("is-counting");
readyText.textContent = "Lock In";
toast("Lock-in cancelled.");
return;
}
let count = 3;
readyBtn.classList.add("is-counting");
readyText.textContent = `Locking ${count}…`;
toast("Locking in — click again to cancel.");
countdownTimer = setInterval(() => {
count -= 1;
if (count > 0) {
readyText.textContent = `Locking ${count}…`;
return;
}
lockIn();
}, 1000);
});
/* ---------- phase timer ---------- */
let phase = 45;
const phaseEl = $("#phaseTimer");
const phaseTick = setInterval(() => {
if (lockedIn) return clearInterval(phaseTick);
phase -= 1;
if (phase < 0) {
clearInterval(phaseTick);
lockIn(); // auto lock-in when timer expires (even mid-countdown)
return;
}
phaseEl.textContent = phase;
if (phase <= 10) phaseEl.parentElement.classList.add("is-urgent");
}, 1000);
/* ---------- init ---------- */
buildRoster();
selectCharacter(selectedId);<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Ashen Vanguard — Character Select</title>
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link href="https://fonts.googleapis.com/css2?family=Orbitron:wght@500;700;900&family=Inter:wght@400;500;600;700;800&display=swap" rel="stylesheet" />
<link rel="stylesheet" href="style.css" />
</head>
<body>
<div class="select-screen" aria-label="Character select screen">
<!-- ===== Top bar ===== -->
<header class="topbar">
<div class="topbar__brand">
<span class="topbar__logo" aria-hidden="true">AV</span>
<div class="topbar__titles">
<span class="topbar__game">Ashen Vanguard</span>
<span class="topbar__mode">Ranked · Breach Protocol · Map: Cinder Bastion</span>
</div>
</div>
<div class="topbar__phase">
<span class="topbar__phase-label">Select your Vanguard</span>
<div class="topbar__timer" role="timer" aria-live="polite">
<span id="phaseTimer">45</span><small>s</small>
</div>
</div>
<div class="topbar__party" aria-label="Party status">
<span class="party-dot party-dot--locked" title="Kestrel-77 locked in"></span>
<span class="party-dot party-dot--locked" title="NovaJinx locked in"></span>
<span class="party-dot party-dot--you" title="You — selecting"></span>
<span class="party-dot" title="Vex_Mori selecting"></span>
<span class="party-dot" title="Drift.exe selecting"></span>
</div>
</header>
<main class="layout">
<!-- ===== Roster ===== -->
<section class="roster" aria-label="Character roster">
<div class="roster__head">
<h2 class="roster__title">Roster</h2>
<span class="roster__count" id="rosterCount">9 / 12 unlocked</span>
</div>
<div class="roster__grid" id="rosterGrid" role="listbox" aria-label="Choose a character">
<!-- tiles injected by script.js -->
</div>
<div class="roster__legend">
<span><i class="legend-swatch legend-swatch--assault"></i> Assault</span>
<span><i class="legend-swatch legend-swatch--support"></i> Support</span>
<span><i class="legend-swatch legend-swatch--recon"></i> Recon</span>
<span><i class="legend-swatch legend-swatch--tank"></i> Bulwark</span>
</div>
</section>
<!-- ===== Featured panel ===== -->
<section class="featured" aria-label="Selected character details" aria-live="polite">
<div class="featured__stage">
<div class="featured__portrait" id="featuredPortrait" aria-hidden="true">
<span class="featured__sigil" id="featuredSigil">VY</span>
<div class="featured__scanline"></div>
</div>
<div class="featured__id">
<span class="featured__role" id="featuredRole">Assault</span>
<h1 class="featured__name" id="featuredName">Vyre</h1>
<p class="featured__epithet" id="featuredEpithet">The Cinder Blade</p>
<div class="featured__difficulty">
<span class="featured__diff-label">Difficulty</span>
<div class="diff-pips" id="diffPips" role="img" aria-label="Difficulty 2 of 3">
<span class="diff-pip"></span><span class="diff-pip"></span><span class="diff-pip"></span>
</div>
</div>
</div>
</div>
<p class="featured__lore" id="featuredLore">
Forged in the ash fields of Old Meridia, Vyre carves through frontlines
with twin plasma edges and a grudge that outlived three wars.
</p>
<div class="featured__columns">
<div class="abilities" aria-label="Abilities">
<h3 class="panel-heading">Abilities</h3>
<ul class="abilities__list" id="abilityList"></ul>
</div>
<div class="stats" aria-label="Combat stats">
<h3 class="panel-heading">Combat Profile</h3>
<div class="stats__rows" id="statRows"></div>
</div>
</div>
</section>
</main>
<!-- ===== Loadout + ready ===== -->
<footer class="dock">
<div class="loadout" aria-label="Loadout">
<h3 class="loadout__title">Loadout</h3>
<div class="loadout__slots">
<button class="slot" type="button" data-slot="primary" aria-label="Primary weapon, click to cycle">
<span class="slot__kind">Primary</span>
<span class="slot__icon" aria-hidden="true">▣</span>
<span class="slot__name" data-slot-name>Helix Carbine</span>
<span class="slot__cycle" aria-hidden="true">⟳</span>
</button>
<button class="slot" type="button" data-slot="secondary" aria-label="Secondary weapon, click to cycle">
<span class="slot__kind">Secondary</span>
<span class="slot__icon" aria-hidden="true">◈</span>
<span class="slot__name" data-slot-name>Arc Pistol</span>
<span class="slot__cycle" aria-hidden="true">⟳</span>
</button>
<button class="slot" type="button" data-slot="perk" aria-label="Perk, click to cycle">
<span class="slot__kind">Perk</span>
<span class="slot__icon" aria-hidden="true">✦</span>
<span class="slot__name" data-slot-name>Adrenal Surge</span>
<span class="slot__cycle" aria-hidden="true">⟳</span>
</button>
</div>
</div>
<div class="ready-zone">
<div class="ready-zone__summary">
<span class="ready-zone__label">Deploying as</span>
<span class="ready-zone__pick" id="readySummary">Vyre — Assault</span>
</div>
<button class="ready-btn" type="button" id="readyBtn">
<span class="ready-btn__text">Lock In</span>
</button>
</div>
</footer>
</div>
<div class="toast" id="toast" role="status" aria-live="polite"></div>
<script src="script.js"></script>
</body>
</html>Character / Loadout Select Screen
A pre-match character select screen in the style of a hero shooter or fighting game, built for the fictional title Ashen Vanguard by studio Nullforge. The left column holds a 12-tile roster grid: each tile is a clipped-corner button with a CSS gradient portrait, character sigil, role icon (Assault, Support, Recon, Bulwark), and a locked state with unlock requirements. Hovering a tile previews it in the featured panel; clicking selects it with a neon glow ring.
The featured panel shows the active Vanguard at full size — angled portrait with scanline overlay, glitch-in name animation, epithet, skewed difficulty pips, lore blurb, a three-ability kit with hotkey badges (the ultimate gets a magenta treatment), and four combat stat bars that re-animate their fill width on every selection. Below, a loadout dock offers primary, secondary, and perk slots that cycle through realistic weapon and perk names on click.
The Lock In button runs a three-second countdown (click again to cancel) before flipping to a green locked state that freezes the roster and loadout, while a 45-second phase timer in the top bar turns red and pulses under 10 seconds, then auto-locks your pick when it expires. A toast(msg) helper narrates every interaction, and the whole layout collapses cleanly down to 360px.
Illustrative UI only — fictional games, studios, characters, and data. Not engine integrations.