DEV Community

Joe Lin for BeGoodTool.com

Posted on

How a Scoreboard Keeps Responsive Layout and Game State in Sync

An online scoreboard is mostly buttons and numbers, but a useful one has to survive team changes, narrow screens, fullscreen mode, and accidental resets. I built the view with a small reactive team model and derived layout values, instead of storing every CSS position as mutable state.

The team list is the source of truth

The initial state is plain data:

const MAX_TEAMS = 18;
const stepOptions = [1, 2, 3, 5];
const teams = ref([
  { id: 1, name: "", score: 0 },
  { id: 2, name: "", score: 0 },
]);

const hasScore = computed(() =>
  teams.value.some((team) => team.score !== 0)
);
Enter fullscreen mode Exit fullscreen mode

Adding a team appends an object with a stable id and refuses to exceed MAX_TEAMS. Removing a team splices the same array. Scores are changed by a delta, so the step control can support one, two, three, or five points without duplicating four button handlers.

Leaders are derived, including ties

The view does not store a “leader” flag on every team:

const leaderIds = computed(() => {
  if (!teams.value.length) return [];
  const max = Math.max(...teams.value.map((team) => team.score));
  return teams.value
    .filter((team) => team.score === max)
    .map((team) => team.id);
});
Enter fullscreen mode Exit fullscreen mode

That naturally handles ties. If two teams have the same highest score, both ids are highlighted. Derived state also avoids stale flags after a score decrement or team removal.

Layout depends on viewport and team count

The number of columns is a computed breakpoint rule:

const maxColumns = computed(() => {
  if (viewportWidth.value <= 768) return 2;
  if (viewportWidth.value <= 1024) return 4;
  return 6;
});

const minLineCount = computed(() =>
  Math.max(1, Math.ceil(teams.value.length / maxColumns.value))
);
Enter fullscreen mode Exit fullscreen mode

The actual CSS variables come from lineCount and columnCount. getGridItemStyle handles an incomplete last row and adjusts it for left, center, or right alignment. That is why the view needs both a responsive width listener and explicit line-count clamping: changing teams can make the previous layout invalid.

LED digits are a rendering adapter

The LED theme does not use a font. It maps each character to segments:

const ledCharacterSegments = {
  0: "abcdef",
  1: "bc",
  2: "abdeg",
  3: "abcdg",
  4: "bcfg",
  8: "abcdefg",
  9: "abcdfg",
  "-": "g",
};

function getLedSegments(character) {
  return ledCharacterSegments[character] || "";
}
Enter fullscreen mode Exit fullscreen mode

getLedCharacters(score) splits the score into characters, allowing negative scores to render the mapped minus segment. An unknown character simply has no segments, which is safer than throwing during a game.

Fullscreen is progressive enhancement

Entering fullscreen first sets the component’s own isFullscreen state, then tries requestFullscreen. If the browser rejects it or does not support it, the CSS mode still works. Escape removes the listener and exits the native document fullscreen when present. That fallback is important on iOS and embedded browsers.

The limitations are practical: state is browser-local, the score has no undo history, and the fullscreen API can be denied by permissions or user gesture rules. A reset uses SweetAlert confirmation, but closing a tab still loses an unsaved match unless the rest of the source’s persistence logic is used. The 18-team cap also makes the layout predictable rather than infinite.

Small defensive rules keep games from becoming layout bugs

clampLineCount is one of those helpers that looks like UI plumbing but protects the actual game. It rounds a user-entered line count, keeps it at least the number of lines required by the current team count, and caps it at the number of teams. After adding or removing a team, syncLineCount recalculates the default unless the player explicitly customized the layout.

The viewport listener follows the same principle. It ignores updates when window.innerWidth has not changed, then synchronizes the grid only when necessary. Without that check, resize events could trigger a stream of redundant reactive updates while someone drags a browser edge. It is not a performance breakthrough, but it keeps the state transitions predictable.

I also like that reset operations are separate. resetScores preserves names, themes, and layout; resetAll returns the whole game to two unnamed teams and the default style. A confirmation dialog protects both actions, but their scopes remain different in the data model. That distinction is exactly what prevents a “reset scores” button from becoming an accidental “erase the match setup” button.

The score itself is not formatted with a fixed width, so a negative value can still be represented as a normal string and then mapped through the minus LED segment. That is a small edge case, but it is a good reminder that a scoreboard is not necessarily a countdown: decrementing below zero should not crash the renderer or silently become an empty display.

The same defensive approach appears in the LED lookup: unknown characters return an empty segment string. A missing mapping becomes a blank glyph rather than an exception that stops every team from rendering.

The responsive calculations also avoid storing pixel coordinates. A team’s position is derived from its index, the current column count, the selected alignment, and the number of empty cells in the final row. When the viewport changes, the same team data can be laid out again. This is less fragile than updating every card’s left and top values and then trying to remember which values belonged to the previous breakpoint.

That is why the scoreboard remains a useful example beyond keeping points. It demonstrates a general Vue pattern: store the smallest meaningful state, derive presentation details from it, and put browser capabilities such as fullscreen behind a fallback. The game remains usable even when a particular visual enhancement is unavailable.

I turned this state-and-layout model into a small free tool: Online Scoreboard.

Top comments (0)