Performance & Fixes
Major rendering performance improvements and a fix for duplicate letter pieces not being recognized as solved.
Major rendering performance improvements and a fix for duplicate letter pieces not being recognized as solved.
Two big things in this update: the game was re-rendering way more than it should have been, and there was a nasty bug where puzzles with duplicate letter pieces could fail validation even when you solved them correctly. Sorry to everyone who ran into that one!
Lettered is built with React and uses a library called PixiJS to draw the game board. React handles the buttons, menus, and overlays while PixiJS handles the actual puzzle canvas where you drag pieces around. They work together but they're separate systems.
The problem was that React was performing way too much subtle work. Every time something changed, React would re-draw the entire screen from scratch.
The game has a timer that ticks every second. Unfortunately for the way the timer state was being managed, this caused React to re-render the entire page layout every second. The jank was especially noticeable when dragging a piece around the board.
The fix was to store the timer value in a ref instead of state. And then query the ref value every second to update the display.
// Instead of: const [elapsedTime, setElapsedTime] = useState(0)
// We use a ref that React ignores:
const elapsedTimeRef = useRef(0);
// Every second, just update the sticky note
setInterval(() => {
elapsedTimeRef.current += 1000;
}, 1000);But the timer still needs to show up on screen. So this GameTimer component's only job is to read that ref and display the time. This component rerenders every 200ms, but lookup cost is negligible. Meanwhile the rest of the app (the canvas, the header, everything else) remains unchanged:
function GameTimer({ elapsedTimeRef }) {
const [display, setDisplay] = useState(formatTime(elapsedTimeRef.current));
useEffect(() => {
// Poll the ref and update just this component
const interval = setInterval(() => {
setDisplay(formatTime(elapsedTimeRef.current));
}, 200);
return ()
This one was dumb on my part. Lettered has a subtle dot grid in the background behind the puzzle. The old code was drawing that grid across the entire virtual canvas, which could be 100x100 cells. That's 10,000 dots and most of them were off-screen and completely invisible.
The fix was to only draw dots within the area that actually has content (the phrase grid and the piece tray). Cuts the number of graphics objects way down and speeds up the initial load. Should have done this from the start honestly.
We're now at a smooth 60fps on even the most complex puzzles. If you're still experiencing jank, shoot me an email at support@lettered.io.
This one was frustrating to track down. Some puzzles have pieces that look identical: same shape, same letters. Imagine a puzzle with two separate "TH" pieces but different colors. When you finish the puzzle the game needs to check if you got it right, and it needs to be smart about identical pieces. If piece A is in slot B and piece B is in slot A, the puzzle is still correct since the pieces are interchangeable.
The way the game handles this is by computing a "signature" for each piece, basically a fingerprint based on its letters and shape. Pieces with the same signature are treated as interchangeable.
Here's where the bug was. Imagine a piece has these two properties:
letters: ['T', 'H'];
positions: [
{ row: 0, col: 1 },
{ row: 0, col: 0 },
];The letter "T" is at position (0,1) and "H" is at position (0,0). To create the signature, the old code sorted the positions to make them consistent, but it didn't rearrange the letters to match:
// After sorting positions:
positions: [
{ row: 0, col: 0 },
{ row: 0, col: 1 },
]; // sorted
letters: ['T', 'H']; // NOT sorted to match
// Signature: "TH:0,0;0,1"Now imagine another identical piece where the arrays happened to be stored in a different internal order:
letters: ['H', 'T'];
positions: [
{ row: 0, col: 0 },
{ row: 0, col: 1 },
];
// After sorting positions:
positions: [
{ row: 0, col: 0 },
{ row: 0, col: 1 },
]; // already sorted
letters: ['H',
"TH:0,0;0,1" vs "HT:0,0;0,1". Two signatures for what is visually the exact same piece. The game thinks they're different, groups them separately, and rejects a valid solution.
The fix: pair each letter with its position first, then sort them together so the letters always follow their positions:
// Zip them together:
[
{ letter: 'T', pos: (0, 1) },
{ letter: 'H', pos: (0, 0) },
][
// Sort by position:
({ letter: 'H', pos: (0, 0) }, { letter: 'T', pos: (0, 1) })
];
// Signature: "HT:0,0;0,1" (always the same regardless of input order)Now two pieces that look the same on the board always produce the same signature, so the validation correctly treats them as interchangeable.