Replaying 3.5 Years of NY Times Crossword Solves

I've been solving the daily NYT crossword since December 2022. I often fall asleep at night phone in hand solving the next day's puzzle. Truthfully, I am not a fast solver, but I've reached the point where I open every puzzle expecting to complete it without hints. Here's a visual of my unassisted solves in that time.

Each curve is a rolling -puzzle average for one day of the week. Click a day to isolate it and see the raw solve points behind the curve. Six of the seven days trend down over the years. Saturday is the holdout, though the increasing density of dots shows I am solving them more frequently.

Online crossword communities focus on per-puzzle solve metrics like the above because that's the data they have available. If you breeze through 95% of a solve in 5 minutes but get stuck in a corner for an hour, was that an easy or hard puzzle? Solve time is a useful metric, but it has limitations. The thing I want to measure (per-answer difficulty) lives at a different granularity.

Luckily, I found that the NYT does retain some solve data down to the cellular level. Here's what it looks like.

{
  "board": {
    "cells": [
      {
        "guess": "R",
        "penciled": true,
        "timestamp": 43694
      },
      {
        "guess": "A",
        "timestamp": 43665
      },
      {
        "guess": "J",
        "penciled": true,
        "timestamp": 43715
      },
      {
        "guess": "A",
        "timestamp": 717
      },
      {
        "guess": "H",
        "penciled": true,
        "timestamp": 43701
      },
      {
        "blank": true
      },
      ...

Unfortunately, it looks like NYT has moved to a new endpoint as of 2026-07-1.

The new game state no longer carries cellular timestamp data :(

There are two major caveats here:

  1. Only the most recent cell guess is recorded. If you overwrite or clear out a cell, that history is lost.
  2. Timestamps are relative to when the puzzle is first opened. If you step away from the app often like I do, this value won't pause to reflect that. There is a separate secondsSpentSolving value which only counts active time solving the puzzle (i.e., you aren't punished for having a life). That's great for the puzzle but doesn't help with this analysis.

To start, I'll walk through the raw data from , which was a × grid.

Puzzle Data

Puzzle data contains grid dimensions, clues, answers, constructors, and the puzzle_id needed to query the game state. You can view a real puzzle object by expanding the JSON object below.

const puzzle = FileAttachment("data/2026-06-28.json").json();

We can then take this puzzle's id () and use it for the next request.

Game State

Game state contains the aforementioned per-cell solve history, plus aggregate metrics like calcs.secondsSpentSolving. Once again, I've embedded a real game state below for testing.

const game = FileAttachment("data/24119.json").json();

Wiring It Up

The body[0].cells[i] in the puzzle JSON references the same grid cell as the board.cells[i] in the game JSON. Zipping them together creates a unified array containing every cell, its grid position, clue label, answer, whether it's a block, user guess, and the timestamp the guess was last entered. The x and y dimensions are easily computed from the index if you know the grid width.

const {width: gridWidth, height: gridHeight} = puzzle.body[0].dimensions;

const cells = puzzle.body[0].cells.map((p, i) => {
  const g = game.board.cells[i] ?? {};
  const blank = !("answer" in p);
  return {
    index: i,
    x: i % gridWidth,
    y: Math.floor(i / gridWidth),
    label: p.label ?? null,
    answer: p.answer ?? null,
    blank,
    guess: blank ? null : g.guess ?? null,
    timestamp: blank ? null : g.timestamp ?? null
  };
});

Remember, cell timestamps are measured from when the puzzle was opened, and they don't pause when you walk away. My solve for this puzzle had a max timestamp of s. That's hours! The actual secondsSpentSolving says only s (:) of that was active.

For a true replay, the grid would sit idle on these timestamp gaps for most of its runtime. Instead, I've opted to limit gaps to the GAP_THRESHOLD (currently seconds). Since we can't know when the active times are, this is the best we can do.

const GAP_THRESHOLD = 60; // seconds

const rawTimes = Array.from(new Set(cells.map((d) => d.timestamp).filter((t) => t != null))).sort((a, b) => a - b);

const adjustedByRaw = new Map();
{
  let cursor = rawTimes[0] ?? 0;
  adjustedByRaw.set(rawTimes[0], cursor);
  for (let i = 1; i < rawTimes.length; i++) {
    cursor += Math.min(rawTimes[i] - rawTimes[i - 1], GAP_THRESHOLD);
    adjustedByRaw.set(rawTimes[i], cursor);
  }
}

const solved = cells.map((d) => ({
  ...d,
  adjustedTimestamp: d.timestamp == null ? null : adjustedByRaw.get(d.timestamp)
}));

const maxTs = d3.max(solved, (d) => d.adjustedTimestamp);
const nonBlank = solved.filter((d) => !d.blank);

Visual Replay

The play/pause control drives a ts value in adjusted seconds. The grid fills in cells whose adjustedTimestamp has passed and tints them by how early they were locked in. This gives a nice visual representation of progression through the different sections of the puzzle. I've defined a playbackRate to speed up the replay (currently x).

/ cells filled at : elapsed (adjusted).

Crossing Dependence

Watching a single game replay is fun, but it doesn't provide any statistical value on its own. Applied across my entire 1000+ puzzle history, though, these same per-cell timestamps could reveal real strengths and weaknesses. I'll start with a simple metric: for a given answer, did I type it in directly, or was it mostly composed from crossing answers?

To quantify this idea, I compare every answer against each of its crossing answers. An answer that finishes after most of its crosses are already in place was probably dependent on them. An answer that finishes while its crossing cells are still open was solved from its own clue. Pseudocode below:

def time_complete(answer):
    return max(cell.time for cell in answer.cells) # when an answer completed

def crossing_dependence(answer):
    answer_ts = time_complete(answer)
    crossed_first_count = 0
    for crossing in answer.crossings:
        crossing_ts = time_complete(crossing)
        if (answer_ts > crossing_ts)
            crossed_first_count += 1
    return crossed_first_count / answers.crossings.count()

Here's the same puzzle from the replay above, but this time each answer is colored by its crossing dependence metric. Since every cell belongs to both an Across and a Down answer, you can toggle between modes. The SW corner is especially interesting as it was mostly solved using the Down clues.

Answer Categorization

Next, I decided to assign the answers into categories. Sorting 86,000 clues into "sports" vs. "opera" vs. "geography" would require a real tagging model (a project for another post). What I can do cheaply is tag by patterns that crossword solvers already watch for: crosswordese (recurring short fill like ERA, ETA, and ALE), proper nouns (PPP-type clues with the last word being capitalized), abbreviations (clues with phrases like "Abbr." or "for short"), and foreign words (clues naming another country/city/language). By flagging each answer based on that criteria, I get the following breakdown.

Unsurprisingly, abbreviations are the answers least dependent on crosses. It helps that these are often 4 cells or fewer in length. Foreign words sit at the opposite extreme, genuinely crossing-dependent.

Recurring Fill

While the term "crosswordese" has a more pointed meaning in the community, I'm using it here to describe answers I've seen 15+ times. Here's a breakdown of my 10 best and worst short fills based on the crossing dependence metric described earlier:

Conclusion

It should be obvious by now that I'm not a data scientist. There's plenty more worth doing with this data like focusing more on the clues rather than the answers or actual topic tagging instead of the shortcuts I used here. On the plus side, I learned quite a bit about my own crossword habits from this exercise. If for some reason you want to dig through my solve data, I've dumped it on my GitHub.

Special thanks to Matt Dodge and xwstats, which was an inspiration for this work, as well as the Observable team for their excellent nyt-minis Notebook.

Disclosure: graph components were built with AI coding assistance