Merge "Track separate reliable and unreliable timestamps for sorting"
diff --git a/aos/network/www/reflection.ts b/aos/network/www/reflection.ts
index a765eea..328ee58 100644
--- a/aos/network/www/reflection.ts
+++ b/aos/network/www/reflection.ts
@@ -6,8 +6,8 @@
 // constructed flatbuffers.
 // See reflection_test_main.ts for sample usage.
 
-import * as aos from 'org_frc971/aos/configuration_generated';
-import * as reflection from '../../../external/com_github_google_flatbuffers/reflection/reflection_generated';
+import * as aos from '../../configuration_generated';
+import * as reflection from 'flatbuffers_reflection/reflection_generated';
 import {ByteBuffer} from 'flatbuffers';
 
 // Returns the size, in bytes, of the given type. For vectors/strings/etc.
diff --git a/aos/util/top.cc b/aos/util/top.cc
index 8a0019d..22d67c8 100644
--- a/aos/util/top.cc
+++ b/aos/util/top.cc
@@ -151,6 +151,11 @@
   aos::monotonic_clock::time_point now = event_loop_->monotonic_now();
   // Get all the processes that we *might* care about.
   std::set<pid_t> pids = pids_to_track_;
+  // Ensure that we check on the status of every process that we are already
+  // tracking.
+  for (const auto & reading : readings_) {
+    pids.insert(reading.first);
+  }
   if (track_all_) {
     DIR *const dir = opendir("/proc");
     if (dir == nullptr) {
diff --git a/frc971/control_loops/control_loop_test.h b/frc971/control_loops/control_loop_test.h
index a820215..576d476 100644
--- a/frc971/control_loops/control_loop_test.h
+++ b/frc971/control_loops/control_loop_test.h
@@ -61,6 +61,9 @@
   void set_team_id(uint16_t team_id) { team_id_ = team_id; }
   uint16_t team_id() const { return team_id_; }
 
+  void set_alliance(aos::Alliance alliance) { alliance_ = alliance; }
+  aos::Alliance alliance() const { return alliance_; }
+
   // Sets the enabled/disabled bit and (potentially) rebroadcasts the robot
   // state messages.
   void SetEnabled(bool enabled) {
@@ -123,9 +126,9 @@
       builder.add_enabled(enabled_);
       builder.add_autonomous(false);
       builder.add_team_id(team_id_);
+      builder.add_alliance(alliance_);
 
-      CHECK_EQ(new_state.Send(builder.Finish()),
-               aos::RawSender::Error::kOk);
+      CHECK_EQ(new_state.Send(builder.Finish()), aos::RawSender::Error::kOk);
 
       last_ds_time_ = monotonic_now();
       last_enabled_ = enabled_;
@@ -165,6 +168,7 @@
   const ::std::chrono::nanoseconds dt_;
 
   uint16_t team_id_ = 971;
+  aos::Alliance alliance_ = aos::Alliance::kInvalid;
   int32_t reader_pid_ = 1;
   double battery_voltage_ = 12.4;
 
diff --git a/scouting/db/db.go b/scouting/db/db.go
index d3faa58..3d514e3 100644
--- a/scouting/db/db.go
+++ b/scouting/db/db.go
@@ -13,17 +13,21 @@
 }
 
 type Match struct {
-	MatchNumber, Round     int32
+	MatchNumber, SetNumber int32
 	CompLevel              string
 	R1, R2, R3, B1, B2, B3 int32
-	// Each of these variables holds the matchID of the corresponding Stats row
-	r1ID, r2ID, r3ID, b1ID, b2ID, b3ID int
+}
+
+type Shift struct {
+	MatchNumber                                                      int32
+	R1scouter, R2scouter, R3scouter, B1scouter, B2scouter, B3scouter string
 }
 
 type Stats struct {
-	TeamNumber, MatchNumber int32
-	StartingQuadrant        int32
-	AutoBallPickedUp        [5]bool
+	TeamNumber, MatchNumber, SetNumber int32
+	CompLevel                          string
+	StartingQuadrant                   int32
+	AutoBallPickedUp                   [5]bool
 	// TODO(phil): Re-order auto and teleop fields so auto comes first.
 	ShotsMissed, UpperGoalShots, LowerGoalShots   int32
 	ShotsMissedAuto, UpperGoalAuto, LowerGoalAuto int32
@@ -35,7 +39,7 @@
 	// 3 -> "Low"
 	// 4 -> "Medium"
 	// 5 -> "High"
-	// 6 -> "Transversal"
+	// 6 -> "Traversal"
 	Climbing int32
 	// Some non-numerical data that the scout felt worth noting.
 	Comment string
@@ -69,9 +73,8 @@
 	}
 
 	statement, err := database.Prepare("CREATE TABLE IF NOT EXISTS matches (" +
-		"id SERIAL PRIMARY KEY, " +
 		"MatchNumber INTEGER, " +
-		"Round INTEGER, " +
+		"SetNumber INTEGER, " +
 		"CompLevel VARCHAR, " +
 		"R1 INTEGER, " +
 		"R2 INTEGER, " +
@@ -79,12 +82,7 @@
 		"B1 INTEGER, " +
 		"B2 INTEGER, " +
 		"B3 INTEGER, " +
-		"r1ID INTEGER, " +
-		"r2ID INTEGER, " +
-		"r3ID INTEGER, " +
-		"b1ID INTEGER, " +
-		"b2ID INTEGER, " +
-		"b3ID INTEGER)")
+		"PRIMARY KEY (MatchNumber, SetNumber, CompLevel))")
 	if err != nil {
 		database.Close()
 		return nil, errors.New(fmt.Sprint("Failed to prepare matches table creation: ", err))
@@ -97,10 +95,32 @@
 		return nil, errors.New(fmt.Sprint("Failed to create matches table: ", err))
 	}
 
-	statement, err = database.Prepare("CREATE TABLE IF NOT EXISTS team_match_stats (" +
+	statement, err = database.Prepare("CREATE TABLE IF NOT EXISTS shift_schedule (" +
 		"id SERIAL PRIMARY KEY, " +
+		"MatchNumber INTEGER, " +
+		"R1Scouter VARCHAR, " +
+		"R2Scouter VARCHAR, " +
+		"R3Scouter VARCHAR, " +
+		"B1Scouter VARCHAR, " +
+		"B2Scouter VARCHAR, " +
+		"B3scouter VARCHAR)")
+	if err != nil {
+		database.Close()
+		return nil, errors.New(fmt.Sprint("Failed to prepare shift schedule table creation: ", err))
+	}
+	defer statement.Close()
+
+	_, err = statement.Exec()
+	if err != nil {
+		database.Close()
+		return nil, errors.New(fmt.Sprint("Failed to create shift schedule table: ", err))
+	}
+
+	statement, err = database.Prepare("CREATE TABLE IF NOT EXISTS team_match_stats (" +
 		"TeamNumber INTEGER, " +
 		"MatchNumber INTEGER, " +
+		"SetNumber INTEGER, " +
+		"CompLevel VARCHAR, " +
 		"StartingQuadrant INTEGER, " +
 		"AutoBall1PickedUp BOOLEAN, " +
 		"AutoBall2PickedUp BOOLEAN, " +
@@ -117,7 +137,8 @@
 		"DefenseReceivedScore INTEGER, " +
 		"Climbing INTEGER, " +
 		"Comment VARCHAR, " +
-		"CollectedBy VARCHAR)")
+		"CollectedBy VARCHAR, " +
+		"PRIMARY KEY (TeamNumber, MatchNumber, SetNumber, CompLevel))")
 	if err != nil {
 		database.Close()
 		return nil, errors.New(fmt.Sprint("Failed to prepare stats table creation: ", err))
@@ -175,6 +196,15 @@
 		return errors.New(fmt.Sprint("Failed to drop matches table: ", err))
 	}
 
+	statement, err = database.Prepare("DROP TABLE IF EXISTS shift_schedule")
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to prepare dropping shifts table: ", err))
+	}
+	_, err = statement.Exec()
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to drop shifts table: ", err))
+	}
+
 	statement, err = database.Prepare("DROP TABLE IF EXISTS team_match_stats")
 	if err != nil {
 		return errors.New(fmt.Sprint("Failed to prepare dropping stats table: ", err))
@@ -207,8 +237,68 @@
 
 // This function will also populate the Stats table with six empty rows every time a match is added
 func (database *Database) AddToMatch(m Match) error {
+	statement, err := database.Prepare("INSERT INTO matches(" +
+		"MatchNumber, SetNumber, CompLevel, " +
+		"R1, R2, R3, B1, B2, B3) " +
+		"VALUES (" +
+		"$1, $2, $3, " +
+		"$4, $5, $6, $7, $8, $9) " +
+		"ON CONFLICT (MatchNumber, SetNumber, CompLevel) DO UPDATE SET " +
+		"R1 = EXCLUDED.R1, R2 = EXCLUDED.R2, R3 = EXCLUDED.R3, " +
+		"B1 = EXCLUDED.B1, B2 = EXCLUDED.B2, B3 = EXCLUDED.B3")
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to prepare insertion into match database: ", err))
+	}
+	defer statement.Close()
+
+	_, err = statement.Exec(m.MatchNumber, m.SetNumber, m.CompLevel,
+		m.R1, m.R2, m.R3, m.B1, m.B2, m.B3)
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to insert into match database: ", err))
+	}
+	return nil
+}
+
+func (database *Database) AddToShift(sh Shift) error {
+	statement, err := database.Prepare("INSERT INTO shift_schedule(" +
+		"MatchNumber, " +
+		"R1scouter, R2scouter, R3scouter, B1scouter, B2scouter, B3scouter) " +
+		"VALUES (" +
+		"$1, " +
+		"$2, $3, $4, $5, $6, $7)")
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to prepare insertion into shift database: ", err))
+	}
+	defer statement.Close()
+
+	_, err = statement.Exec(sh.MatchNumber,
+		sh.R1scouter, sh.R2scouter, sh.R3scouter, sh.B1scouter, sh.B2scouter, sh.B3scouter)
+	if err != nil {
+		return errors.New(fmt.Sprint("Failed to insert into shift database: ", err))
+	}
+	return nil
+}
+
+func (database *Database) AddToStats(s Stats) error {
+	matches, err := database.QueryMatches(s.TeamNumber)
+	if err != nil {
+		return err
+	}
+	foundMatch := false
+	for _, match := range matches {
+		if match.MatchNumber == s.MatchNumber {
+			foundMatch = true
+			break
+		}
+	}
+	if !foundMatch {
+		return errors.New(fmt.Sprint(
+			"Failed to find team ", s.TeamNumber,
+			" in match ", s.MatchNumber, " in the schedule."))
+	}
+
 	statement, err := database.Prepare("INSERT INTO team_match_stats(" +
-		"TeamNumber, MatchNumber, " +
+		"TeamNumber, MatchNumber, SetNumber, CompLevel, " +
 		"StartingQuadrant, " +
 		"AutoBall1PickedUp, AutoBall2PickedUp, AutoBall3PickedUp, " +
 		"AutoBall4PickedUp, AutoBall5PickedUp, " +
@@ -217,98 +307,32 @@
 		"PlayedDefense, DefenseReceivedScore, Climbing, " +
 		"Comment, CollectedBy) " +
 		"VALUES (" +
-		"$1, $2, " +
-		"$3, " +
-		"$4, $5, $6, " +
-		"$7, $8, " +
-		"$9, $10, $11, " +
-		"$12, $13, $14, " +
-		"$15, $16, $17, " +
-		"$18, $19) " +
-		"RETURNING id")
-	if err != nil {
-		return errors.New(fmt.Sprint("Failed to prepare insertion into stats database: ", err))
-	}
-	defer statement.Close()
-
-	var rowIds [6]int64
-	for i, TeamNumber := range []int32{m.R1, m.R2, m.R3, m.B1, m.B2, m.B3} {
-		row := statement.QueryRow(
-			TeamNumber, m.MatchNumber,
-			0,
-			false, false, false,
-			false, false,
-			0, 0, 0,
-			0, 0, 0,
-			0, 0, 0,
-			"", "")
-		err = row.Scan(&rowIds[i])
-		if err != nil {
-			return errors.New(fmt.Sprint("Failed to insert stats: ", err))
-		}
-	}
-
-	statement, err = database.Prepare("INSERT INTO matches(" +
-		"MatchNumber, Round, CompLevel, " +
-		"R1, R2, R3, B1, B2, B3, " +
-		"r1ID, r2ID, r3ID, b1ID, b2ID, b3ID) " +
-		"VALUES (" +
-		"$1, $2, $3, " +
-		"$4, $5, $6, $7, $8, $9, " +
-		"$10, $11, $12, $13, $14, $15)")
-	if err != nil {
-		return errors.New(fmt.Sprint("Failed to prepare insertion into match database: ", err))
-	}
-	defer statement.Close()
-
-	_, err = statement.Exec(m.MatchNumber, m.Round, m.CompLevel,
-		m.R1, m.R2, m.R3, m.B1, m.B2, m.B3,
-		rowIds[0], rowIds[1], rowIds[2], rowIds[3], rowIds[4], rowIds[5])
-	if err != nil {
-		return errors.New(fmt.Sprint("Failed to insert into match database: ", err))
-	}
-	return nil
-}
-
-func (database *Database) AddToStats(s Stats) error {
-	statement, err := database.Prepare("UPDATE team_match_stats SET " +
-		"TeamNumber = $1, MatchNumber = $2, " +
-		"StartingQuadrant = $3, " +
-		"AutoBall1PickedUp = $4, AutoBall2PickedUp = $5, AutoBall3PickedUp = $6, " +
-		"AutoBall4PickedUp = $7, AutoBall5PickedUp = $8, " +
-		"ShotsMissed = $9, UpperGoalShots = $10, LowerGoalShots = $11, " +
-		"ShotsMissedAuto = $12, UpperGoalAuto = $13, LowerGoalAuto = $14, " +
-		"PlayedDefense = $15, DefenseReceivedScore = $16, Climbing = $17, " +
-		"Comment = $18, CollectedBy = $19 " +
-		"WHERE MatchNumber = $20 AND TeamNumber = $21")
+		"$1, $2, $3, $4, " +
+		"$5, " +
+		"$6, $7, $8, " +
+		"$9, $10, " +
+		"$11, $12, $13, " +
+		"$14, $15, $16, " +
+		"$17, $18, $19, " +
+		"$20, $21)")
 	if err != nil {
 		return errors.New(fmt.Sprint("Failed to prepare stats update statement: ", err))
 	}
 	defer statement.Close()
 
-	result, err := statement.Exec(
-		s.TeamNumber, s.MatchNumber,
+	_, err = statement.Exec(
+		s.TeamNumber, s.MatchNumber, s.SetNumber, s.CompLevel,
 		s.StartingQuadrant,
 		s.AutoBallPickedUp[0], s.AutoBallPickedUp[1], s.AutoBallPickedUp[2],
 		s.AutoBallPickedUp[3], s.AutoBallPickedUp[4],
 		s.ShotsMissed, s.UpperGoalShots, s.LowerGoalShots,
 		s.ShotsMissedAuto, s.UpperGoalAuto, s.LowerGoalAuto,
 		s.PlayedDefense, s.DefenseReceivedScore, s.Climbing,
-		s.Comment, s.CollectedBy,
-		s.MatchNumber, s.TeamNumber)
+		s.Comment, s.CollectedBy)
 	if err != nil {
 		return errors.New(fmt.Sprint("Failed to update stats database: ", err))
 	}
 
-	numRowsAffected, err := result.RowsAffected()
-	if err != nil {
-		return errors.New(fmt.Sprint("Failed to query rows affected: ", err))
-	}
-	if numRowsAffected == 0 {
-		return errors.New(fmt.Sprint(
-			"Failed to find team ", s.TeamNumber,
-			" in match ", s.MatchNumber, " in the schedule."))
-	}
 	return nil
 }
 
@@ -364,10 +388,8 @@
 	matches := make([]Match, 0)
 	for rows.Next() {
 		var match Match
-		var id int
-		err := rows.Scan(&id, &match.MatchNumber, &match.Round, &match.CompLevel,
-			&match.R1, &match.R2, &match.R3, &match.B1, &match.B2, &match.B3,
-			&match.r1ID, &match.r2ID, &match.r3ID, &match.b1ID, &match.b2ID, &match.b3ID)
+		err := rows.Scan(&match.MatchNumber, &match.SetNumber, &match.CompLevel,
+			&match.R1, &match.R2, &match.R3, &match.B1, &match.B2, &match.B3)
 		if err != nil {
 			return nil, errors.New(fmt.Sprint("Failed to scan from matches: ", err))
 		}
@@ -376,6 +398,27 @@
 	return matches, nil
 }
 
+func (database *Database) ReturnAllShifts() ([]Shift, error) {
+	rows, err := database.Query("SELECT * FROM shift_schedule")
+	if err != nil {
+		return nil, errors.New(fmt.Sprint("Failed to select from shift: ", err))
+	}
+	defer rows.Close()
+
+	shifts := make([]Shift, 0)
+	for rows.Next() {
+		var shift Shift
+		var id int
+		err := rows.Scan(&id, &shift.MatchNumber,
+			&shift.R1scouter, &shift.R2scouter, &shift.R3scouter, &shift.B1scouter, &shift.B2scouter, &shift.B3scouter)
+		if err != nil {
+			return nil, errors.New(fmt.Sprint("Failed to scan from shift: ", err))
+		}
+		shifts = append(shifts, shift)
+	}
+	return shifts, nil
+}
+
 func (database *Database) ReturnStats() ([]Stats, error) {
 	rows, err := database.Query("SELECT * FROM team_match_stats")
 	if err != nil {
@@ -386,9 +429,8 @@
 	teams := make([]Stats, 0)
 	for rows.Next() {
 		var team Stats
-		var id int
-		err = rows.Scan(&id,
-			&team.TeamNumber, &team.MatchNumber,
+		err = rows.Scan(
+			&team.TeamNumber, &team.MatchNumber, &team.SetNumber, &team.CompLevel,
 			&team.StartingQuadrant,
 			&team.AutoBallPickedUp[0], &team.AutoBallPickedUp[1], &team.AutoBallPickedUp[2],
 			&team.AutoBallPickedUp[3], &team.AutoBallPickedUp[4],
@@ -438,10 +480,8 @@
 	var matches []Match
 	for rows.Next() {
 		var match Match
-		var id int
-		err = rows.Scan(&id, &match.MatchNumber, &match.Round, &match.CompLevel,
-			&match.R1, &match.R2, &match.R3, &match.B1, &match.B2, &match.B3,
-			&match.r1ID, &match.r2ID, &match.r3ID, &match.b1ID, &match.b2ID, &match.b3ID)
+		err = rows.Scan(&match.MatchNumber, &match.SetNumber, &match.CompLevel,
+			&match.R1, &match.R2, &match.R3, &match.B1, &match.B2, &match.B3)
 		if err != nil {
 			return nil, errors.New(fmt.Sprint("Failed to scan from matches: ", err))
 		}
@@ -450,6 +490,27 @@
 	return matches, nil
 }
 
+func (database *Database) QueryAllShifts(matchNumber_ int) ([]Shift, error) {
+	rows, err := database.Query("SELECT * FROM shift_schedule WHERE MatchNumber = $1", matchNumber_)
+	if err != nil {
+		return nil, errors.New(fmt.Sprint("Failed to select from shift for team: ", err))
+	}
+	defer rows.Close()
+
+	var shifts []Shift
+	for rows.Next() {
+		var shift Shift
+		var id int
+		err = rows.Scan(&id, &shift.MatchNumber,
+			&shift.R1scouter, &shift.R2scouter, &shift.R3scouter, &shift.B1scouter, &shift.B2scouter, &shift.B3scouter)
+		if err != nil {
+			return nil, errors.New(fmt.Sprint("Failed to scan from matches: ", err))
+		}
+		shifts = append(shifts, shift)
+	}
+	return shifts, nil
+}
+
 func (database *Database) QueryStats(teamNumber_ int) ([]Stats, error) {
 	rows, err := database.Query("SELECT * FROM team_match_stats WHERE TeamNumber = $1", teamNumber_)
 	if err != nil {
@@ -460,9 +521,8 @@
 	var teams []Stats
 	for rows.Next() {
 		var team Stats
-		var id int
-		err = rows.Scan(&id,
-			&team.TeamNumber, &team.MatchNumber,
+		err = rows.Scan(
+			&team.TeamNumber, &team.MatchNumber, &team.SetNumber, &team.CompLevel,
 			&team.StartingQuadrant,
 			&team.AutoBallPickedUp[0], &team.AutoBallPickedUp[1], &team.AutoBallPickedUp[2],
 			&team.AutoBallPickedUp[3], &team.AutoBallPickedUp[4],
diff --git a/scouting/db/db_test.go b/scouting/db/db_test.go
index 5725dcb..438e52e 100644
--- a/scouting/db/db_test.go
+++ b/scouting/db/db_test.go
@@ -6,6 +6,7 @@
 	"os"
 	"os/exec"
 	"reflect"
+	"strings"
 	"testing"
 	"time"
 )
@@ -64,10 +65,9 @@
 	correct := []Match{
 		Match{
 			MatchNumber: 7,
-			Round:       1,
+			SetNumber:   1,
 			CompLevel:   "quals",
 			R1:          9999, R2: 1000, R3: 777, B1: 0000, B2: 4321, B3: 1234,
-			r1ID: 1, r2ID: 2, r3ID: 3, b1ID: 4, b2ID: 5, b3ID: 6,
 		},
 	}
 
@@ -174,9 +174,8 @@
 	}
 
 	err := fixture.db.AddToMatch(Match{
-		MatchNumber: 7, Round: 1, CompLevel: "quals",
+		MatchNumber: 7, SetNumber: 1, CompLevel: "quals",
 		R1: 1236, R2: 1001, R3: 777, B1: 1000, B2: 4321, B3: 1234,
-		r1ID: 1, r2ID: 2, r3ID: 3, b1ID: 4, b2ID: 5, b3ID: 6,
 	})
 	check(t, err, "Failed to add match")
 
@@ -193,15 +192,49 @@
 	}
 }
 
+func TestAddDuplicateStats(t *testing.T) {
+	fixture := createDatabase(t)
+	defer fixture.TearDown()
+
+	stats := Stats{
+		TeamNumber: 1236, MatchNumber: 7,
+		StartingQuadrant: 2,
+		AutoBallPickedUp: [5]bool{false, false, false, true, false},
+		ShotsMissed:      9, UpperGoalShots: 5, LowerGoalShots: 4,
+		ShotsMissedAuto: 3, UpperGoalAuto: 2, LowerGoalAuto: 1,
+		PlayedDefense: 2, DefenseReceivedScore: 0, Climbing: 3,
+		Comment: "this is a comment", CollectedBy: "josh",
+	}
+
+	err := fixture.db.AddToMatch(Match{
+		MatchNumber: 7, SetNumber: 1, CompLevel: "quals",
+		R1: 1236, R2: 1001, R3: 777, B1: 1000, B2: 4321, B3: 1234,
+	})
+	check(t, err, "Failed to add match")
+
+	// Add stats. This should succeed.
+	err = fixture.db.AddToStats(stats)
+	check(t, err, "Failed to add stats to DB")
+
+	// Try again. It should fail this time.
+	err = fixture.db.AddToStats(stats)
+	if err == nil {
+		t.Fatal("Failed to get error when adding duplicate stats.")
+	}
+	if !strings.Contains(err.Error(), "ERROR: duplicate key value violates unique constraint") {
+		t.Fatal("Expected error message to be complain about duplicate key value, but got ", err)
+	}
+}
+
 func TestQueryMatchDB(t *testing.T) {
 	fixture := createDatabase(t)
 	defer fixture.TearDown()
 
 	testDatabase := []Match{
-		Match{MatchNumber: 2, Round: 1, CompLevel: "quals", R1: 251, R2: 169, R3: 286, B1: 253, B2: 538, B3: 149},
-		Match{MatchNumber: 4, Round: 1, CompLevel: "quals", R1: 198, R2: 135, R3: 777, B1: 999, B2: 434, B3: 698},
-		Match{MatchNumber: 3, Round: 1, CompLevel: "quals", R1: 147, R2: 421, R3: 538, B1: 126, B2: 448, B3: 262},
-		Match{MatchNumber: 6, Round: 1, CompLevel: "quals", R1: 191, R2: 132, R3: 773, B1: 994, B2: 435, B3: 696},
+		Match{MatchNumber: 2, SetNumber: 1, CompLevel: "quals", R1: 251, R2: 169, R3: 286, B1: 253, B2: 538, B3: 149},
+		Match{MatchNumber: 4, SetNumber: 1, CompLevel: "quals", R1: 198, R2: 135, R3: 777, B1: 999, B2: 434, B3: 698},
+		Match{MatchNumber: 3, SetNumber: 1, CompLevel: "quals", R1: 147, R2: 421, R3: 538, B1: 126, B2: 448, B3: 262},
+		Match{MatchNumber: 6, SetNumber: 1, CompLevel: "quals", R1: 191, R2: 132, R3: 773, B1: 994, B2: 435, B3: 696},
 	}
 
 	for i := 0; i < len(testDatabase); i++ {
@@ -211,14 +244,12 @@
 
 	correct := []Match{
 		Match{
-			MatchNumber: 2, Round: 1, CompLevel: "quals",
+			MatchNumber: 2, SetNumber: 1, CompLevel: "quals",
 			R1: 251, R2: 169, R3: 286, B1: 253, B2: 538, B3: 149,
-			r1ID: 1, r2ID: 2, r3ID: 3, b1ID: 4, b2ID: 5, b3ID: 6,
 		},
 		Match{
-			MatchNumber: 3, Round: 1, CompLevel: "quals",
+			MatchNumber: 3, SetNumber: 1, CompLevel: "quals",
 			R1: 147, R2: 421, R3: 538, B1: 126, B2: 448, B3: 262,
-			r1ID: 13, r2ID: 14, r3ID: 15, b1ID: 16, b2ID: 17, b3ID: 18,
 		},
 	}
 
@@ -230,20 +261,55 @@
 	}
 }
 
+func TestQueryShiftDB(t *testing.T) {
+	fixture := createDatabase(t)
+	defer fixture.TearDown()
+
+	testDatabase := []Shift{
+		Shift{
+			MatchNumber: 1,
+			R1scouter:   "Bob1", R2scouter: "Bob2", R3scouter: "Bob3", B1scouter: "Alice1", B2scouter: "Alice2", B3scouter: "Alice3",
+		},
+		Shift{
+			MatchNumber: 2,
+			R1scouter:   "Bob1", R2scouter: "Bob2", R3scouter: "Bob3", B1scouter: "Alice1", B2scouter: "Alice2", B3scouter: "Alice3",
+		},
+	}
+
+	for i := 0; i < len(testDatabase); i++ {
+		err := fixture.db.AddToShift(testDatabase[i])
+		check(t, err, fmt.Sprint("Failed to add shift", i))
+	}
+
+	correct := []Shift{
+		Shift{
+			MatchNumber: 1,
+			R1scouter:   "Bob1", R2scouter: "Bob2", R3scouter: "Bob3", B1scouter: "Alice1", B2scouter: "Alice2", B3scouter: "Alice3",
+		},
+	}
+
+	got, err := fixture.db.QueryAllShifts(1)
+	check(t, err, "Failed to query shift for match 1")
+
+	if !reflect.DeepEqual(correct, got) {
+		t.Fatalf("Got %#v,\nbut expected %#v.", got, correct)
+	}
+}
+
 func TestQueryStatsDB(t *testing.T) {
 	fixture := createDatabase(t)
 	defer fixture.TearDown()
 
 	testDatabase := []Stats{
 		Stats{
-			TeamNumber: 1235, MatchNumber: 94,
+			TeamNumber: 1235, MatchNumber: 94, SetNumber: 2, CompLevel: "quals",
 			StartingQuadrant: 1,
 			AutoBallPickedUp: [5]bool{false, false, false, false, false},
 			ShotsMissed:      2, UpperGoalShots: 2, LowerGoalShots: 2,
 			ShotsMissedAuto: 2, UpperGoalAuto: 2, LowerGoalAuto: 2,
 			PlayedDefense: 2, DefenseReceivedScore: 1, Climbing: 2},
 		Stats{
-			TeamNumber: 1234, MatchNumber: 94,
+			TeamNumber: 1234, MatchNumber: 94, SetNumber: 2, CompLevel: "quals",
 			StartingQuadrant: 2,
 			AutoBallPickedUp: [5]bool{false, false, false, false, true},
 			ShotsMissed:      4, UpperGoalShots: 4, LowerGoalShots: 4,
@@ -251,7 +317,7 @@
 			PlayedDefense: 7, DefenseReceivedScore: 1, Climbing: 2,
 		},
 		Stats{
-			TeamNumber: 1233, MatchNumber: 94,
+			TeamNumber: 1233, MatchNumber: 94, SetNumber: 2, CompLevel: "quals",
 			StartingQuadrant: 3,
 			AutoBallPickedUp: [5]bool{false, false, false, false, false},
 			ShotsMissed:      3, UpperGoalShots: 3, LowerGoalShots: 3,
@@ -259,7 +325,7 @@
 			PlayedDefense: 3, DefenseReceivedScore: 0, Climbing: 3,
 		},
 		Stats{
-			TeamNumber: 1232, MatchNumber: 94,
+			TeamNumber: 1232, MatchNumber: 94, SetNumber: 2, CompLevel: "quals",
 			StartingQuadrant: 2,
 			AutoBallPickedUp: [5]bool{true, false, false, false, true},
 			ShotsMissed:      5, UpperGoalShots: 5, LowerGoalShots: 5,
@@ -267,7 +333,7 @@
 			PlayedDefense: 7, DefenseReceivedScore: 2, Climbing: 1,
 		},
 		Stats{
-			TeamNumber: 1231, MatchNumber: 94,
+			TeamNumber: 1231, MatchNumber: 94, SetNumber: 2, CompLevel: "quals",
 			StartingQuadrant: 3,
 			AutoBallPickedUp: [5]bool{false, false, true, false, false},
 			ShotsMissed:      6, UpperGoalShots: 6, LowerGoalShots: 6,
@@ -275,7 +341,7 @@
 			PlayedDefense: 7, DefenseReceivedScore: 3, Climbing: 1,
 		},
 		Stats{
-			TeamNumber: 1239, MatchNumber: 94,
+			TeamNumber: 1239, MatchNumber: 94, SetNumber: 2, CompLevel: "quals",
 			StartingQuadrant: 4,
 			AutoBallPickedUp: [5]bool{false, true, true, false, false},
 			ShotsMissed:      7, UpperGoalShots: 7, LowerGoalShots: 7,
@@ -285,7 +351,7 @@
 	}
 
 	err := fixture.db.AddToMatch(Match{
-		MatchNumber: 94, Round: 1, CompLevel: "quals",
+		MatchNumber: 94, SetNumber: 1, CompLevel: "quals",
 		R1: 1235, R2: 1234, R3: 1233, B1: 1232, B2: 1231, B3: 1239})
 	check(t, err, "Failed to add match")
 
@@ -296,7 +362,7 @@
 
 	correct := []Stats{
 		Stats{
-			TeamNumber: 1235, MatchNumber: 94,
+			TeamNumber: 1235, MatchNumber: 94, SetNumber: 2, CompLevel: "quals",
 			StartingQuadrant: 1,
 			AutoBallPickedUp: [5]bool{false, false, false, false, false},
 			ShotsMissed:      2, UpperGoalShots: 2, LowerGoalShots: 2,
@@ -367,29 +433,24 @@
 
 	correct := []Match{
 		Match{
-			MatchNumber: 2, Round: 1, CompLevel: "quals",
+			MatchNumber: 2, SetNumber: 1, CompLevel: "quals",
 			R1: 251, R2: 169, R3: 286, B1: 253, B2: 538, B3: 149,
-			r1ID: 1, r2ID: 2, r3ID: 3, b1ID: 4, b2ID: 5, b3ID: 6,
 		},
 		Match{
-			MatchNumber: 3, Round: 1, CompLevel: "quals",
+			MatchNumber: 3, SetNumber: 1, CompLevel: "quals",
 			R1: 147, R2: 421, R3: 538, B1: 126, B2: 448, B3: 262,
-			r1ID: 7, r2ID: 8, r3ID: 9, b1ID: 10, b2ID: 11, b3ID: 12,
 		},
 		Match{
-			MatchNumber: 4, Round: 1, CompLevel: "quals",
+			MatchNumber: 4, SetNumber: 1, CompLevel: "quals",
 			R1: 251, R2: 169, R3: 286, B1: 653, B2: 538, B3: 149,
-			r1ID: 13, r2ID: 14, r3ID: 15, b1ID: 16, b2ID: 17, b3ID: 18,
 		},
 		Match{
-			MatchNumber: 5, Round: 1, CompLevel: "quals",
+			MatchNumber: 5, SetNumber: 1, CompLevel: "quals",
 			R1: 198, R2: 1421, R3: 538, B1: 26, B2: 448, B3: 262,
-			r1ID: 19, r2ID: 20, r3ID: 21, b1ID: 22, b2ID: 23, b3ID: 24,
 		},
 		Match{
-			MatchNumber: 6, Round: 1, CompLevel: "quals",
+			MatchNumber: 6, SetNumber: 1, CompLevel: "quals",
 			R1: 251, R2: 188, R3: 286, B1: 555, B2: 538, B3: 149,
-			r1ID: 25, r2ID: 26, r3ID: 27, b1ID: 28, b2ID: 29, b3ID: 30,
 		},
 	}
 
@@ -406,6 +467,77 @@
 	}
 }
 
+func TestOverwriteNewMatchData(t *testing.T) {
+	fixture := createDatabase(t)
+	defer fixture.TearDown()
+
+	testDatabase := []Match{
+		Match{
+			MatchNumber: 1, SetNumber: 1, CompLevel: "quals",
+			R1: 251, R2: 169, R3: 286, B1: 253, B2: 538, B3: 149,
+		},
+		Match{
+			MatchNumber: 2, SetNumber: 1, CompLevel: "quals",
+			R1: 198, R2: 135, R3: 777, B1: 999, B2: 434, B3: 698,
+		},
+		Match{
+			MatchNumber: 1, SetNumber: 1, CompLevel: "quals",
+			R1: 147, R2: 421, R3: 538, B1: 126, B2: 448, B3: 262,
+		},
+	}
+
+	for i := 0; i < len(testDatabase); i++ {
+		err := fixture.db.AddToMatch(testDatabase[i])
+		check(t, err, fmt.Sprint("Failed to add match", i))
+	}
+
+	correct := []Match{
+		Match{
+			MatchNumber: 2, SetNumber: 1, CompLevel: "quals",
+			R1: 198, R2: 135, R3: 777, B1: 999, B2: 434, B3: 698,
+		},
+		Match{
+			MatchNumber: 1, SetNumber: 1, CompLevel: "quals",
+			R1: 147, R2: 421, R3: 538, B1: 126, B2: 448, B3: 262,
+		},
+	}
+
+	got, err := fixture.db.ReturnMatches()
+	check(t, err, "Failed to get match list")
+
+	if !reflect.DeepEqual(correct, got) {
+		t.Fatalf("Got %#v,\nbut expected %#v.", got, correct)
+	}
+}
+
+func TestAddReturnShiftDB(t *testing.T) {
+	fixture := createDatabase(t)
+	defer fixture.TearDown()
+
+	correct := []Shift{
+		Shift{
+			MatchNumber: 1,
+			R1scouter:   "Bob1", R2scouter: "Bob2", R3scouter: "Bob3", B1scouter: "Alice1", B2scouter: "Alice2", B3scouter: "Alice3",
+		},
+		Shift{
+			MatchNumber: 2,
+			R1scouter:   "Bob1", R2scouter: "Bob2", R3scouter: "Bob3", B1scouter: "Alice1", B2scouter: "Alice2", B3scouter: "Alice3",
+		},
+	}
+
+	for i := 0; i < len(correct); i++ {
+		err := fixture.db.AddToShift(correct[i])
+		check(t, err, fmt.Sprint("Failed to add shift", i))
+	}
+
+	got, err := fixture.db.ReturnAllShifts()
+	check(t, err, "Failed ReturnAllShifts()")
+
+	if !reflect.DeepEqual(correct, got) {
+		t.Errorf("Got %#v,\nbut expected %#v.", got, correct)
+	}
+}
+
 func TestReturnRankingsDB(t *testing.T) {
 	fixture := createDatabase(t)
 	defer fixture.TearDown()
@@ -452,14 +584,14 @@
 
 	correct := []Stats{
 		Stats{
-			TeamNumber: 1235, MatchNumber: 94,
+			TeamNumber: 1235, MatchNumber: 94, SetNumber: 1, CompLevel: "quals",
 			StartingQuadrant: 1,
 			AutoBallPickedUp: [5]bool{false, false, false, false, false},
 			ShotsMissed:      2, UpperGoalShots: 2, LowerGoalShots: 2,
 			ShotsMissedAuto: 2, UpperGoalAuto: 2, LowerGoalAuto: 2,
 			PlayedDefense: 2, DefenseReceivedScore: 3, Climbing: 2},
 		Stats{
-			TeamNumber: 1236, MatchNumber: 94,
+			TeamNumber: 1236, MatchNumber: 94, SetNumber: 1, CompLevel: "quals",
 			StartingQuadrant: 2,
 			AutoBallPickedUp: [5]bool{false, false, false, false, true},
 			ShotsMissed:      4, UpperGoalShots: 4, LowerGoalShots: 4,
@@ -467,7 +599,7 @@
 			PlayedDefense: 7, DefenseReceivedScore: 1, Climbing: 2,
 		},
 		Stats{
-			TeamNumber: 1237, MatchNumber: 94,
+			TeamNumber: 1237, MatchNumber: 94, SetNumber: 1, CompLevel: "quals",
 			StartingQuadrant: 3,
 			AutoBallPickedUp: [5]bool{false, false, false, false, false},
 			ShotsMissed:      3, UpperGoalShots: 3, LowerGoalShots: 3,
@@ -475,7 +607,7 @@
 			PlayedDefense: 3, DefenseReceivedScore: 0, Climbing: 3,
 		},
 		Stats{
-			TeamNumber: 1238, MatchNumber: 94,
+			TeamNumber: 1238, MatchNumber: 94, SetNumber: 1, CompLevel: "quals",
 			StartingQuadrant: 2,
 			AutoBallPickedUp: [5]bool{true, false, false, false, true},
 			ShotsMissed:      5, UpperGoalShots: 5, LowerGoalShots: 5,
@@ -483,7 +615,7 @@
 			PlayedDefense: 7, DefenseReceivedScore: 4, Climbing: 1,
 		},
 		Stats{
-			TeamNumber: 1239, MatchNumber: 94,
+			TeamNumber: 1239, MatchNumber: 94, SetNumber: 1, CompLevel: "quals",
 			StartingQuadrant: 3,
 			AutoBallPickedUp: [5]bool{false, false, true, false, false},
 			ShotsMissed:      6, UpperGoalShots: 6, LowerGoalShots: 6,
@@ -491,7 +623,7 @@
 			PlayedDefense: 7, DefenseReceivedScore: 4, Climbing: 1,
 		},
 		Stats{
-			TeamNumber: 1233, MatchNumber: 94,
+			TeamNumber: 1233, MatchNumber: 94, SetNumber: 1, CompLevel: "quals",
 			StartingQuadrant: 4,
 			AutoBallPickedUp: [5]bool{false, true, true, false, false},
 			ShotsMissed:      7, UpperGoalShots: 7, LowerGoalShots: 7,
@@ -501,7 +633,7 @@
 	}
 
 	err := fixture.db.AddToMatch(Match{
-		MatchNumber: 94, Round: 1, CompLevel: "quals",
+		MatchNumber: 94, SetNumber: 1, CompLevel: "quals",
 		R1: 1235, R2: 1236, R3: 1237, B1: 1238, B2: 1239, B3: 1233})
 	check(t, err, "Failed to add match")
 
diff --git a/scouting/scouting_test.ts b/scouting/scouting_test.ts
index 98e1d00..e983a86 100644
--- a/scouting/scouting_test.ts
+++ b/scouting/scouting_test.ts
@@ -41,6 +41,12 @@
   return element(by.css('.error_message')).getText();
 }
 
+// Returns the currently displayed error message on the screen. This only
+// exists on screens where the web page interacts with the web server.
+function getValueOfInputById(id: string) {
+  return element(by.id(id)).getAttribute('value');
+}
+
 // Asserts that the field on the "Submit and Review" screen has a specific
 // value.
 function expectReviewFieldToBe(fieldName: string, expectedValue: string) {
@@ -120,14 +126,39 @@
   it('should: show matches in chronological order.', async () => {
     await loadPage();
 
-    expect(await getNthMatchLabel(0)).toEqual('Quals 1');
-    expect(await getNthMatchLabel(1)).toEqual('Quals 2');
-    expect(await getNthMatchLabel(2)).toEqual('Quals 3');
-    expect(await getNthMatchLabel(9)).toEqual('Quals 10');
-    // TODO(phil): Validate quarter finals and friends. Right now we don't
-    // distinguish between "sets". I.e. we display 4 "Quarter Final 1" matches
-    // without being able to distinguish between them.
-    expect(await getNthMatchLabel(87)).toEqual('Final 1');
+    expect(await getNthMatchLabel(0)).toEqual('Quals Match 1');
+    expect(await getNthMatchLabel(1)).toEqual('Quals Match 2');
+    expect(await getNthMatchLabel(2)).toEqual('Quals Match 3');
+    expect(await getNthMatchLabel(9)).toEqual('Quals Match 10');
+    expect(await getNthMatchLabel(72)).toEqual('Quarter Final 1 Match 1');
+    expect(await getNthMatchLabel(73)).toEqual('Quarter Final 2 Match 1');
+    expect(await getNthMatchLabel(74)).toEqual('Quarter Final 3 Match 1');
+    expect(await getNthMatchLabel(75)).toEqual('Quarter Final 4 Match 1');
+    expect(await getNthMatchLabel(76)).toEqual('Quarter Final 1 Match 2');
+    expect(await getNthMatchLabel(82)).toEqual('Semi Final 1 Match 1');
+    expect(await getNthMatchLabel(83)).toEqual('Semi Final 2 Match 1');
+    expect(await getNthMatchLabel(84)).toEqual('Semi Final 1 Match 2');
+    expect(await getNthMatchLabel(85)).toEqual('Semi Final 2 Match 2');
+    expect(await getNthMatchLabel(89)).toEqual('Final 1 Match 3');
+  });
+
+  it('should: prefill the match information.', async () => {
+    await loadPage();
+
+    expect(await getHeadingText()).toEqual('Matches');
+
+    // On the 87th row of matches (index 86) click on the second team
+    // (index 1) which resolves to team 5254 in semi final 2 match 3.
+    await element
+      .all(by.css('button.match-item'))
+      .get(86 * 6 + 1)
+      .click();
+
+    expect(await getHeadingText()).toEqual('Team Selection');
+    expect(await getValueOfInputById('match_number')).toEqual('3');
+    expect(await getValueOfInputById('team_number')).toEqual('5254');
+    expect(await getValueOfInputById('set_number')).toEqual('2');
+    expect(await getValueOfInputById('comp_level')).toEqual('3: sf');
   });
 
   it('should: error on unknown match.', async () => {
@@ -197,6 +228,8 @@
     expect(await getHeadingText()).toEqual('Team Selection');
     await setTextboxByIdTo('match_number', '2');
     await setTextboxByIdTo('team_number', '5254');
+    await setTextboxByIdTo('set_number', '42');
+    await element(by.cssContainingText('option', 'Semi Finals')).click();
     await element(by.buttonText('Next')).click();
 
     expect(await getHeadingText()).toEqual('Auto');
@@ -224,6 +257,8 @@
     // Validate Team Selection.
     await expectReviewFieldToBe('Match number', '2');
     await expectReviewFieldToBe('Team number', '5254');
+    await expectReviewFieldToBe('SetNumber', '42');
+    await expectReviewFieldToBe('Comp Level', 'Semi Finals');
 
     // Validate Auto.
     await expectNthReviewFieldToBe('Upper Shots Made', 0, '0');
@@ -237,7 +272,7 @@
     await expectNthReviewFieldToBe('Missed Shots', 1, '0');
 
     // Validate Climb.
-    await expectReviewFieldToBe('Level', 'High');
+    await expectReviewFieldToBe('Climb Level', 'High');
     await expectReviewFieldToBe('Comments', 'A very useful comment here.');
 
     // Validate Other.
diff --git a/scouting/scraping/BUILD b/scouting/scraping/BUILD
index c643192..ed21433 100644
--- a/scouting/scraping/BUILD
+++ b/scouting/scraping/BUILD
@@ -6,6 +6,8 @@
         # Generated with: bazel run //scouting/scraping:scraping_demo -- --json
         "test_data/2016_nytr.json",
         "test_data/2020_fake.json",
+        # Generated with: bazel run scouting/scraping:scraping_demo -- -category rankings -json >scouting/scraping/test_data/2016_nytr_rankings.json
+        "test_data/2016_nytr_rankings.json",
     ],
     visibility = ["//visibility:public"],
 )
diff --git a/scouting/scraping/scrape.go b/scouting/scraping/scrape.go
index 19426cf..625157a 100644
--- a/scouting/scraping/scrape.go
+++ b/scouting/scraping/scrape.go
@@ -13,8 +13,10 @@
 
 // Stores the TBA API key to access the API.
 type scrapingConfig struct {
-	ApiKey  string `json:"api_key"`
-	BaseUrl string `json:"base_url"`
+	ApiKey    string `json:"api_key"`
+	BaseUrl   string `json:"base_url"`
+	Year      int32  `json:"year"`
+	EventCode string `json:"event_code"`
 }
 
 // Takes in year and FIRST event code and returns requested information according to TBA.
@@ -47,9 +49,15 @@
 	if config.BaseUrl == "" {
 		config.BaseUrl = "https://www.thebluealliance.com"
 	}
+	if config.Year == 0 {
+		config.Year = year
+	}
+	if config.EventCode == "" {
+		config.EventCode = eventCode
+	}
 
 	// Create the TBA event key for the year and event code.
-	eventKey := strconv.Itoa(int(year)) + eventCode
+	eventKey := strconv.Itoa(int(config.Year)) + config.EventCode
 
 	// Create a get request for the match info.
 	req, err := http.NewRequest("GET", config.BaseUrl+"/api/v3/event/"+eventKey+"/"+category, nil)
diff --git a/scouting/scraping/test_data/2016_nytr_rankings.json b/scouting/scraping/test_data/2016_nytr_rankings.json
new file mode 100644
index 0000000..dc7ba1a
--- /dev/null
+++ b/scouting/scraping/test_data/2016_nytr_rankings.json
@@ -0,0 +1,796 @@
+{
+  "rankings": [
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        3.25
+      ],
+      "sort_orders": [
+        39,
+        310,
+        165,
+        448,
+        600
+      ],
+      "record": {
+        "losses": 1,
+        "wins": 11,
+        "ties": 0
+      },
+      "rank": 1,
+      "dq": 0,
+      "team_key": "frc359"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        3.0833333333333335
+      ],
+      "sort_orders": [
+        37,
+        288,
+        145,
+        298,
+        615
+      ],
+      "record": {
+        "losses": 1,
+        "wins": 11,
+        "ties": 0
+      },
+      "rank": 2,
+      "dq": 0,
+      "team_key": "frc5254"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        2.8333333333333335
+      ],
+      "sort_orders": [
+        34,
+        252,
+        205,
+        311,
+        590
+      ],
+      "record": {
+        "losses": 1,
+        "wins": 11,
+        "ties": 0
+      },
+      "rank": 3,
+      "dq": 0,
+      "team_key": "frc3990"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        2.5833333333333335
+      ],
+      "sort_orders": [
+        31,
+        180,
+        145,
+        236,
+        550
+      ],
+      "record": {
+        "losses": 4,
+        "wins": 8,
+        "ties": 0
+      },
+      "rank": 4,
+      "dq": 0,
+      "team_key": "frc5236"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        2.5
+      ],
+      "sort_orders": [
+        30,
+        304,
+        140,
+        284,
+        615
+      ],
+      "record": {
+        "losses": 4,
+        "wins": 8,
+        "ties": 0
+      },
+      "rank": 5,
+      "dq": 0,
+      "team_key": "frc3419"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        2.3333333333333335
+      ],
+      "sort_orders": [
+        28,
+        260,
+        150,
+        191,
+        575
+      ],
+      "record": {
+        "losses": 3,
+        "wins": 9,
+        "ties": 0
+      },
+      "rank": 6,
+      "dq": 0,
+      "team_key": "frc5240"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        2.3333333333333335
+      ],
+      "sort_orders": [
+        28,
+        254,
+        125,
+        208,
+        595
+      ],
+      "record": {
+        "losses": 4,
+        "wins": 8,
+        "ties": 0
+      },
+      "rank": 7,
+      "dq": 0,
+      "team_key": "frc20"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        2.0833333333333335
+      ],
+      "sort_orders": [
+        25,
+        266,
+        125,
+        272,
+        575
+      ],
+      "record": {
+        "losses": 5,
+        "wins": 7,
+        "ties": 0
+      },
+      "rank": 8,
+      "dq": 0,
+      "team_key": "frc48"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        2.0833333333333335
+      ],
+      "sort_orders": [
+        25,
+        260,
+        140,
+        155,
+        550
+      ],
+      "record": {
+        "losses": 4,
+        "wins": 8,
+        "ties": 0
+      },
+      "rank": 9,
+      "dq": 0,
+      "team_key": "frc250"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        2
+      ],
+      "sort_orders": [
+        24,
+        200,
+        145,
+        227,
+        560
+      ],
+      "record": {
+        "losses": 5,
+        "wins": 7,
+        "ties": 0
+      },
+      "rank": 10,
+      "dq": 0,
+      "team_key": "frc2791"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        2
+      ],
+      "sort_orders": [
+        24,
+        166,
+        155,
+        119,
+        510
+      ],
+      "record": {
+        "losses": 5,
+        "wins": 7,
+        "ties": 0
+      },
+      "rank": 11,
+      "dq": 0,
+      "team_key": "frc358"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        1.9166666666666667
+      ],
+      "sort_orders": [
+        23,
+        278,
+        120,
+        160,
+        585
+      ],
+      "record": {
+        "losses": 5,
+        "wins": 7,
+        "ties": 0
+      },
+      "rank": 12,
+      "dq": 0,
+      "team_key": "frc4930"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        1.9166666666666667
+      ],
+      "sort_orders": [
+        23,
+        268,
+        120,
+        131,
+        655
+      ],
+      "record": {
+        "losses": 6,
+        "wins": 6,
+        "ties": 0
+      },
+      "rank": 13,
+      "dq": 0,
+      "team_key": "frc3044"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        1.9166666666666667
+      ],
+      "sort_orders": [
+        23,
+        262,
+        120,
+        176,
+        550
+      ],
+      "record": {
+        "losses": 5,
+        "wins": 7,
+        "ties": 0
+      },
+      "rank": 14,
+      "dq": 0,
+      "team_key": "frc527"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        1.8333333333333333
+      ],
+      "sort_orders": [
+        22,
+        192,
+        140,
+        212,
+        515
+      ],
+      "record": {
+        "losses": 6,
+        "wins": 6,
+        "ties": 0
+      },
+      "rank": 15,
+      "dq": 0,
+      "team_key": "frc3003"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        1.75
+      ],
+      "sort_orders": [
+        21,
+        300,
+        120,
+        275,
+        560
+      ],
+      "record": {
+        "losses": 6,
+        "wins": 6,
+        "ties": 0
+      },
+      "rank": 16,
+      "dq": 0,
+      "team_key": "frc333"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        1.6666666666666667
+      ],
+      "sort_orders": [
+        20,
+        196,
+        160,
+        144,
+        530
+      ],
+      "record": {
+        "losses": 7,
+        "wins": 5,
+        "ties": 0
+      },
+      "rank": 17,
+      "dq": 0,
+      "team_key": "frc1551"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        1.6666666666666667
+      ],
+      "sort_orders": [
+        20,
+        192,
+        105,
+        146,
+        525
+      ],
+      "record": {
+        "losses": 6,
+        "wins": 6,
+        "ties": 0
+      },
+      "rank": 18,
+      "dq": 0,
+      "team_key": "frc1665"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        1.6666666666666667
+      ],
+      "sort_orders": [
+        20,
+        166,
+        125,
+        187,
+        555
+      ],
+      "record": {
+        "losses": 8,
+        "wins": 4,
+        "ties": 0
+      },
+      "rank": 19,
+      "dq": 0,
+      "team_key": "frc663"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        1.6666666666666667
+      ],
+      "sort_orders": [
+        20,
+        156,
+        130,
+        119,
+        525
+      ],
+      "record": {
+        "losses": 6,
+        "wins": 6,
+        "ties": 0
+      },
+      "rank": 20,
+      "dq": 0,
+      "team_key": "frc229"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        1.5833333333333333
+      ],
+      "sort_orders": [
+        19,
+        316,
+        115,
+        167,
+        570
+      ],
+      "record": {
+        "losses": 7,
+        "wins": 5,
+        "ties": 0
+      },
+      "rank": 21,
+      "dq": 0,
+      "team_key": "frc4093"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        1.5833333333333333
+      ],
+      "sort_orders": [
+        19,
+        236,
+        155,
+        148,
+        575
+      ],
+      "record": {
+        "losses": 7,
+        "wins": 5,
+        "ties": 0
+      },
+      "rank": 22,
+      "dq": 0,
+      "team_key": "frc1493"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        1.5833333333333333
+      ],
+      "sort_orders": [
+        19,
+        218,
+        110,
+        159,
+        520
+      ],
+      "record": {
+        "losses": 6,
+        "wins": 6,
+        "ties": 0
+      },
+      "rank": 23,
+      "dq": 0,
+      "team_key": "frc5964"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        1.5833333333333333
+      ],
+      "sort_orders": [
+        19,
+        214,
+        130,
+        246,
+        485
+      ],
+      "record": {
+        "losses": 6,
+        "wins": 6,
+        "ties": 0
+      },
+      "rank": 24,
+      "dq": 0,
+      "team_key": "frc145"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        1.4166666666666667
+      ],
+      "sort_orders": [
+        17,
+        184,
+        110,
+        122,
+        440
+      ],
+      "record": {
+        "losses": 6,
+        "wins": 6,
+        "ties": 0
+      },
+      "rank": 25,
+      "dq": 0,
+      "team_key": "frc371"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        1.3333333333333333
+      ],
+      "sort_orders": [
+        16,
+        224,
+        145,
+        160,
+        550
+      ],
+      "record": {
+        "losses": 8,
+        "wins": 4,
+        "ties": 0
+      },
+      "rank": 26,
+      "dq": 0,
+      "team_key": "frc5881"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        1.3333333333333333
+      ],
+      "sort_orders": [
+        16,
+        188,
+        105,
+        170,
+        385
+      ],
+      "record": {
+        "losses": 4,
+        "wins": 5,
+        "ties": 0
+      },
+      "rank": 27,
+      "dq": 0,
+      "team_key": "frc3624"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        1.25
+      ],
+      "sort_orders": [
+        15,
+        224,
+        95,
+        141,
+        525
+      ],
+      "record": {
+        "losses": 8,
+        "wins": 4,
+        "ties": 0
+      },
+      "rank": 28,
+      "dq": 0,
+      "team_key": "frc4508"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        1.25
+      ],
+      "sort_orders": [
+        15,
+        224,
+        75,
+        113,
+        515
+      ],
+      "record": {
+        "losses": 7,
+        "wins": 5,
+        "ties": 0
+      },
+      "rank": 29,
+      "dq": 0,
+      "team_key": "frc5585"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        1.25
+      ],
+      "sort_orders": [
+        15,
+        214,
+        130,
+        203,
+        505
+      ],
+      "record": {
+        "losses": 8,
+        "wins": 4,
+        "ties": 0
+      },
+      "rank": 30,
+      "dq": 0,
+      "team_key": "frc4481"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        1.25
+      ],
+      "sort_orders": [
+        15,
+        156,
+        115,
+        101,
+        485
+      ],
+      "record": {
+        "losses": 7,
+        "wins": 4,
+        "ties": 0
+      },
+      "rank": 31,
+      "dq": 0,
+      "team_key": "frc4856"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        1.0833333333333333
+      ],
+      "sort_orders": [
+        13,
+        204,
+        110,
+        92,
+        480
+      ],
+      "record": {
+        "losses": 7,
+        "wins": 4,
+        "ties": 0
+      },
+      "rank": 32,
+      "dq": 0,
+      "team_key": "frc5879"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        0.9166666666666666
+      ],
+      "sort_orders": [
+        11,
+        228,
+        110,
+        115,
+        500
+      ],
+      "record": {
+        "losses": 9,
+        "wins": 3,
+        "ties": 0
+      },
+      "rank": 33,
+      "dq": 0,
+      "team_key": "frc1450"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        0.8333333333333334
+      ],
+      "sort_orders": [
+        10,
+        160,
+        90,
+        131,
+        490
+      ],
+      "record": {
+        "losses": 10,
+        "wins": 2,
+        "ties": 0
+      },
+      "rank": 34,
+      "dq": 0,
+      "team_key": "frc5943"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        0.6666666666666666
+      ],
+      "sort_orders": [
+        8,
+        162,
+        60,
+        121,
+        430
+      ],
+      "record": {
+        "losses": 10,
+        "wins": 2,
+        "ties": 0
+      },
+      "rank": 35,
+      "dq": 0,
+      "team_key": "frc4203"
+    },
+    {
+      "matches_played": 12,
+      "qual_average": 0,
+      "extra_stats": [
+        0.6666666666666666
+      ],
+      "sort_orders": [
+        8,
+        144,
+        65,
+        140,
+        430
+      ],
+      "record": {
+        "losses": 10,
+        "wins": 2,
+        "ties": 0
+      },
+      "rank": 36,
+      "dq": 0,
+      "team_key": "frc5149"
+    }
+  ]
+}
diff --git a/scouting/webserver/BUILD b/scouting/webserver/BUILD
index 745852a..3df423e 100644
--- a/scouting/webserver/BUILD
+++ b/scouting/webserver/BUILD
@@ -9,6 +9,7 @@
     deps = [
         "//scouting/db",
         "//scouting/scraping",
+        "//scouting/webserver/rankings",
         "//scouting/webserver/requests",
         "//scouting/webserver/server",
         "//scouting/webserver/static",
diff --git a/scouting/webserver/main.go b/scouting/webserver/main.go
index 8f4298b..5d4ab01 100644
--- a/scouting/webserver/main.go
+++ b/scouting/webserver/main.go
@@ -15,6 +15,7 @@
 
 	"github.com/frc971/971-Robot-Code/scouting/db"
 	"github.com/frc971/971-Robot-Code/scouting/scraping"
+	"github.com/frc971/971-Robot-Code/scouting/webserver/rankings"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/requests"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/server"
 	"github.com/frc971/971-Robot-Code/scouting/webserver/static"
@@ -131,6 +132,9 @@
 	scoutingServer.Start(*portPtr)
 	fmt.Println("Serving", *dirPtr, "on port", *portPtr)
 
+	scraper := rankings.RankingScraper{}
+	scraper.Start(database, 0, "", *blueAllianceConfigPtr)
+
 	// Block until the user hits Ctrl-C.
 	sigint := make(chan os.Signal, 1)
 	signal.Notify(sigint, syscall.SIGINT)
@@ -140,5 +144,6 @@
 
 	fmt.Println("Shutting down.")
 	scoutingServer.Stop()
+	scraper.Stop()
 	fmt.Println("Successfully shut down.")
 }
diff --git a/scouting/webserver/rankings/BUILD b/scouting/webserver/rankings/BUILD
new file mode 100644
index 0000000..c74f88f
--- /dev/null
+++ b/scouting/webserver/rankings/BUILD
@@ -0,0 +1,26 @@
+load("@io_bazel_rules_go//go:def.bzl", "go_library", "go_test")
+
+go_library(
+    name = "rankings",
+    srcs = ["rankings.go"],
+    importpath = "github.com/frc971/971-Robot-Code/scouting/webserver/rankings",
+    visibility = ["//visibility:public"],
+    deps = [
+        "//scouting/db",
+        "//scouting/scraping",
+    ],
+)
+
+go_test(
+    name = "rankings_test",
+    srcs = ["rankings_test.go"],
+    data = [
+        "scouting_test_config.json",
+        "//scouting/scraping:test_data",
+    ],
+    embed = [":rankings"],
+    deps = [
+        "//scouting/db",
+        "//scouting/webserver/server",
+    ],
+)
diff --git a/scouting/webserver/rankings/rankings.go b/scouting/webserver/rankings/rankings.go
new file mode 100644
index 0000000..6e63c0a
--- /dev/null
+++ b/scouting/webserver/rankings/rankings.go
@@ -0,0 +1,92 @@
+package rankings
+
+import (
+	"github.com/frc971/971-Robot-Code/scouting/db"
+	"github.com/frc971/971-Robot-Code/scouting/scraping"
+	"log"
+	"strconv"
+	"strings"
+	"time"
+)
+
+type RankingScraper struct {
+	doneChan     chan<- bool
+	checkStopped chan<- bool
+}
+
+type Database interface {
+	AddOrUpdateRankings(db.Ranking) error
+}
+
+func parseTeamKey(teamKey string) (int, error) {
+	// TBA prefixes teams with "frc". Not sure why. Get rid of that.
+	teamKey = strings.TrimPrefix(teamKey, "frc")
+	return strconv.Atoi(teamKey)
+}
+
+func getRankings(database Database, year int32, eventCode string, blueAllianceConfig string) {
+	rankings, err := scraping.AllRankings(year, eventCode, blueAllianceConfig)
+	if err != nil {
+		log.Println("Failed to scrape ranking list: ", err)
+		return
+	}
+
+	for _, rank := range rankings.Rankings {
+		teamKey, err := parseTeamKey(rank.TeamKey)
+
+		if err != nil {
+			log.Println("Failed to parse team key: ", err)
+			continue
+		}
+
+		rankingInfo := db.Ranking{
+			TeamNumber: teamKey,
+			Losses:     rank.Records.Losses, Wins: rank.Records.Wins, Ties: rank.Records.Ties,
+			Rank: rank.Rank, Dq: rank.Dq,
+		}
+		err = database.AddOrUpdateRankings(rankingInfo)
+
+		if err != nil {
+			log.Println("Failed to add or update database: ", err)
+		}
+	}
+}
+
+func (scraper *RankingScraper) Start(database Database, year int32, eventCode string, blueAllianceConfig string) {
+	scraper.doneChan = make(chan bool, 1)
+	scraper.checkStopped = make(chan bool, 1)
+
+	go func(database Database, year int32, eventCode string) {
+		// Setting start time to 11 minutes prior so getRankings called instantly when Start() called
+		startTime := time.Now().Add(-11 * time.Minute)
+		for {
+			curTime := time.Now()
+			diff := curTime.Sub(startTime)
+
+			if diff.Minutes() > 10 {
+				getRankings(database, year, eventCode, blueAllianceConfig)
+				startTime = curTime
+			}
+
+			if len(scraper.doneChan) != 0 {
+				break
+			}
+
+			time.Sleep(time.Second)
+		}
+
+		scraper.checkStopped <- true
+	}(database, year, eventCode)
+}
+
+func (scraper *RankingScraper) Stop() {
+	scraper.doneChan <- true
+
+	for {
+		if len(scraper.checkStopped) != 0 {
+			close(scraper.doneChan)
+			close(scraper.checkStopped)
+			break
+		}
+	}
+}
diff --git a/scouting/webserver/rankings/rankings_test.go b/scouting/webserver/rankings/rankings_test.go
new file mode 100644
index 0000000..aa23c76
--- /dev/null
+++ b/scouting/webserver/rankings/rankings_test.go
@@ -0,0 +1,69 @@
+package rankings
+
+import (
+	"github.com/frc971/971-Robot-Code/scouting/db"
+	"github.com/frc971/971-Robot-Code/scouting/webserver/server"
+	"net/http"
+	"reflect"
+	"testing"
+	"time"
+)
+
+type MockDatabase struct {
+	rankings []db.Ranking
+}
+
+func (database *MockDatabase) AddOrUpdateRankings(data db.Ranking) error {
+	database.rankings = append(database.rankings, data)
+	return nil
+}
+
+func ServeRankings(h http.Handler) http.Handler {
+	fn := func(w http.ResponseWriter, r *http.Request) {
+		r.URL.Path = "scraping/test_data/2016_nytr_rankings.json"
+
+		h.ServeHTTP(w, r)
+	}
+
+	return http.HandlerFunc(fn)
+}
+
+func TestGetRankings(t *testing.T) {
+	database := MockDatabase{}
+	scraper := RankingScraper{}
+	tbaServer := server.NewScoutingServer()
+	tbaServer.Handle("/", ServeRankings(http.FileServer(http.Dir("../../"))))
+	tbaServer.Start(8000)
+	defer tbaServer.Stop()
+
+	scraper.Start(&database, 0, "", "scouting_test_config.json")
+	defer scraper.Stop()
+
+	for {
+		if len(database.rankings) > 0 {
+			break
+		}
+
+		time.Sleep(time.Second)
+	}
+
+	beginningThreeExpected := []db.Ranking{
+		{TeamNumber: 359, Losses: 1, Wins: 11, Ties: 0, Rank: 1, Dq: 0},
+		{TeamNumber: 5254, Losses: 1, Wins: 11, Ties: 0, Rank: 2, Dq: 0},
+		{TeamNumber: 3990, Losses: 1, Wins: 11, Ties: 0, Rank: 3, Dq: 0},
+	}
+
+	endThreeExpected := []db.Ranking{
+		{TeamNumber: 5943, Losses: 10, Wins: 2, Ties: 0, Rank: 34, Dq: 0},
+		{TeamNumber: 4203, Losses: 10, Wins: 2, Ties: 0, Rank: 35, Dq: 0},
+		{TeamNumber: 5149, Losses: 10, Wins: 2, Ties: 0, Rank: 36, Dq: 0},
+	}
+
+	if !reflect.DeepEqual(beginningThreeExpected, database.rankings[0:3]) {
+		t.Fatal("Got %#v, but expected %#v.", database.rankings[0:3], beginningThreeExpected)
+	}
+
+	if !reflect.DeepEqual(endThreeExpected, database.rankings[33:]) {
+		t.Fatal("Got %#v, but expected %#v.", database.rankings[33:], beginningThreeExpected)
+	}
+}
diff --git a/scouting/webserver/rankings/scouting_test_config.json b/scouting/webserver/rankings/scouting_test_config.json
new file mode 100644
index 0000000..40a7747
--- /dev/null
+++ b/scouting/webserver/rankings/scouting_test_config.json
@@ -0,0 +1,6 @@
+{
+     "api_key": "dummy_key_that_is_not_actually_used_in_this_test",
+     "base_url": "http://localhost:8000",
+     "year": 2022,
+     "event_code": "CMPTX"
+}
\ No newline at end of file
diff --git a/scouting/webserver/requests/debug/cli/cli_test.py b/scouting/webserver/requests/debug/cli/cli_test.py
index 5b0c749..2bcd75b 100644
--- a/scouting/webserver/requests/debug/cli/cli_test.py
+++ b/scouting/webserver/requests/debug/cli/cli_test.py
@@ -52,7 +52,7 @@
             "event_code": event_code,
         })
         exit_code, stdout, stderr = run_debug_cli(["-refreshMatchList", json_path])
-        self.assertEqual(exit_code, 0, stderr)
+        self.assertEqual(exit_code, 0, f"{year}{event_code}: {stderr}")
         self.assertIn("(refresh_match_list_response.RefreshMatchListResponseT)", stdout)
 
     def test_submit_and_request_data_scouting(self):
@@ -62,6 +62,8 @@
         json_path = write_json_request({
             "team": 100,
             "match": 1,
+            "set_number": 2,
+            "comp_level": "quals",
             "starting_quadrant": 3,
             "auto_ball_1": True,
             "auto_ball_2": False,
@@ -108,7 +110,9 @@
             StartingQuadrant: (int32) 3,
             ClimbLevel: (request_data_scouting_response.ClimbLevel) Medium,
             DefenseReceivedRating: (int32) 4,
-            Comment: (string) (len=35) "A very inspiring and useful comment"
+            Comment: (string) (len=35) "A very inspiring and useful comment",
+            SetNumber: (int32) 2,
+            CompLevel: (string) (len=5) "quals"
             }"""), stdout)
 
     def test_request_all_matches(self):
@@ -136,5 +140,21 @@
         self.assertEqual(stdout.count("MatchNumber:"), 12)
         self.assertEqual(len(re.findall(r": \(int32\) 4856[,\n]", stdout)), 12)
 
+    def test_request_all_matches(self):
+        """Makes sure that we can import the match list multiple times without problems."""
+        request_all_matches_outputs = []
+        for _ in range(2):
+            self.refresh_match_list()
+
+            # RequestAllMatches has no fields.
+            json_path = write_json_request({})
+            exit_code, stdout, stderr = run_debug_cli(["-requestAllMatches", json_path])
+
+            self.assertEqual(exit_code, 0, stderr)
+            request_all_matches_outputs.append(stdout)
+
+        self.maxDiff = None
+        self.assertEqual(request_all_matches_outputs[0], request_all_matches_outputs[1])
+
 if __name__ == "__main__":
     unittest.main()
diff --git a/scouting/webserver/requests/debug/cli/main.go b/scouting/webserver/requests/debug/cli/main.go
index f6fb38a..d338b5f 100644
--- a/scouting/webserver/requests/debug/cli/main.go
+++ b/scouting/webserver/requests/debug/cli/main.go
@@ -97,6 +97,9 @@
 
 	spew.Config.Indent = *indentPtr
 
+	// Disable pointer addresses. They're not useful for our purposes.
+	spew.Config.DisablePointerAddresses = true
+
 	// Handle the actual arguments.
 	maybePerformRequest(
 		"SubmitDataScouting",
diff --git a/scouting/webserver/requests/messages/request_all_matches_response.fbs b/scouting/webserver/requests/messages/request_all_matches_response.fbs
index 90401e3..9d3be62 100644
--- a/scouting/webserver/requests/messages/request_all_matches_response.fbs
+++ b/scouting/webserver/requests/messages/request_all_matches_response.fbs
@@ -2,7 +2,7 @@
 
 table Match {
     match_number:int (id: 0);
-    round:int (id: 1);
+    set_number:int (id: 1);
     comp_level:string (id: 2);
     r1:int (id: 3);
     r2:int (id: 4);
diff --git a/scouting/webserver/requests/messages/request_data_scouting_response.fbs b/scouting/webserver/requests/messages/request_data_scouting_response.fbs
index 3987d7e..071a848 100644
--- a/scouting/webserver/requests/messages/request_data_scouting_response.fbs
+++ b/scouting/webserver/requests/messages/request_data_scouting_response.fbs
@@ -10,12 +10,14 @@
     Low,
     Medium,
     High,
-    Transversal,
+    Traversal,
 }
 
 table Stats {
     team:int (id: 0);
     match:int (id: 1);
+    set_number:int (id: 20);
+    comp_level:string (id: 21);
 
     missed_shots_auto:int (id: 2);
     upper_goal_auto:int (id:3);
diff --git a/scouting/webserver/requests/messages/request_matches_for_team_response.fbs b/scouting/webserver/requests/messages/request_matches_for_team_response.fbs
index cbb1895..dcfec52 100644
--- a/scouting/webserver/requests/messages/request_matches_for_team_response.fbs
+++ b/scouting/webserver/requests/messages/request_matches_for_team_response.fbs
@@ -2,7 +2,7 @@
 
 table Match {
     match_number:int (id: 0);
-    round:int (id: 1);
+    set_number:int (id: 1);
     comp_level:string (id: 2);
     r1:int (id: 3);
     r2:int (id: 4);
diff --git a/scouting/webserver/requests/messages/submit_data_scouting.fbs b/scouting/webserver/requests/messages/submit_data_scouting.fbs
index e136e71..36990f5 100644
--- a/scouting/webserver/requests/messages/submit_data_scouting.fbs
+++ b/scouting/webserver/requests/messages/submit_data_scouting.fbs
@@ -10,12 +10,15 @@
     Low,
     Medium,
     High,
-    Transversal,
+    Traversal,
 }
 
 table SubmitDataScouting {
     team:int (id: 0);
     match:int (id: 1);
+    set_number:int (id: 19);
+    comp_level:string (id: 20);
+
     missed_shots_auto:int (id: 2);
     upper_goal_auto:int (id:3);
     lower_goal_auto:int (id:4);
diff --git a/scouting/webserver/requests/requests.go b/scouting/webserver/requests/requests.go
index d67f5e9..cef87aa 100644
--- a/scouting/webserver/requests/requests.go
+++ b/scouting/webserver/requests/requests.go
@@ -144,6 +144,8 @@
 	stats := db.Stats{
 		TeamNumber:       request.Team(),
 		MatchNumber:      request.Match(),
+		SetNumber:        request.SetNumber(),
+		CompLevel:        string(request.CompLevel()),
 		StartingQuadrant: request.StartingQuadrant(),
 		AutoBallPickedUp: [5]bool{
 			request.AutoBall1(), request.AutoBall2(), request.AutoBall3(),
@@ -207,7 +209,7 @@
 	for _, match := range matches {
 		response.MatchList = append(response.MatchList, &request_all_matches_response.MatchT{
 			MatchNumber: match.MatchNumber,
-			Round:       match.Round,
+			SetNumber:   match.SetNumber,
 			CompLevel:   match.CompLevel,
 			R1:          match.R1,
 			R2:          match.R2,
@@ -250,7 +252,7 @@
 	for _, match := range matches {
 		response.MatchList = append(response.MatchList, &request_all_matches_response.MatchT{
 			MatchNumber: match.MatchNumber,
-			Round:       match.Round,
+			SetNumber:   match.SetNumber,
 			CompLevel:   match.CompLevel,
 			R1:          match.R1,
 			R2:          match.R2,
@@ -294,6 +296,8 @@
 		response.StatsList = append(response.StatsList, &request_data_scouting_response.StatsT{
 			Team:                  stat.TeamNumber,
 			Match:                 stat.MatchNumber,
+			SetNumber:             stat.SetNumber,
+			CompLevel:             stat.CompLevel,
 			StartingQuadrant:      stat.StartingQuadrant,
 			AutoBall1:             stat.AutoBallPickedUp[0],
 			AutoBall2:             stat.AutoBallPickedUp[1],
@@ -391,15 +395,14 @@
 		// Add the match to the database.
 		err = handler.db.AddToMatch(db.Match{
 			MatchNumber: int32(match.MatchNumber),
-			// TODO(phil): What does Round mean?
-			Round:     1,
-			CompLevel: match.CompLevel,
-			R1:        red[0],
-			R2:        red[1],
-			R3:        red[2],
-			B1:        blue[0],
-			B2:        blue[1],
-			B3:        blue[2],
+			SetNumber:   int32(match.SetNumber),
+			CompLevel:   match.CompLevel,
+			R1:          red[0],
+			R2:          red[1],
+			R3:          red[2],
+			B1:          blue[0],
+			B2:          blue[1],
+			B3:          blue[2],
 		})
 		if err != nil {
 			respondWithError(w, http.StatusInternalServerError, fmt.Sprintf(
diff --git a/scouting/webserver/requests/requests_test.go b/scouting/webserver/requests/requests_test.go
index b864cf3..4dda143 100644
--- a/scouting/webserver/requests/requests_test.go
+++ b/scouting/webserver/requests/requests_test.go
@@ -84,6 +84,8 @@
 	builder.Finish((&submit_data_scouting.SubmitDataScoutingT{
 		Team:                  971,
 		Match:                 1,
+		SetNumber:             8,
+		CompLevel:             "quals",
 		StartingQuadrant:      2,
 		AutoBall1:             true,
 		AutoBall2:             false,
@@ -119,15 +121,15 @@
 	db := MockDatabase{
 		matches: []db.Match{
 			{
-				MatchNumber: 1, Round: 1, CompLevel: "qual",
+				MatchNumber: 1, SetNumber: 1, CompLevel: "qual",
 				R1: 5, R2: 42, R3: 600, B1: 971, B2: 400, B3: 200,
 			},
 			{
-				MatchNumber: 2, Round: 1, CompLevel: "qual",
+				MatchNumber: 2, SetNumber: 1, CompLevel: "qual",
 				R1: 6, R2: 43, R3: 601, B1: 972, B2: 401, B3: 201,
 			},
 			{
-				MatchNumber: 3, Round: 1, CompLevel: "qual",
+				MatchNumber: 3, SetNumber: 1, CompLevel: "qual",
 				R1: 7, R2: 44, R3: 602, B1: 973, B2: 402, B3: 202,
 			},
 		},
@@ -147,7 +149,7 @@
 
 	expected := request_all_matches_response.RequestAllMatchesResponseT{
 		MatchList: []*request_all_matches_response.MatchT{
-			// MatchNumber, Round, CompLevel
+			// MatchNumber, SetNumber, CompLevel
 			// R1, R2, R3, B1, B2, B3
 			{
 				1, 1, "qual",
@@ -179,11 +181,11 @@
 	db := MockDatabase{
 		matches: []db.Match{
 			{
-				MatchNumber: 1, Round: 1, CompLevel: "qual",
+				MatchNumber: 1, SetNumber: 1, CompLevel: "qual",
 				R1: 5, R2: 42, R3: 600, B1: 971, B2: 400, B3: 200,
 			},
 			{
-				MatchNumber: 2, Round: 1, CompLevel: "qual",
+				MatchNumber: 2, SetNumber: 1, CompLevel: "qual",
 				R1: 6, R2: 43, R3: 601, B1: 972, B2: 401, B3: 201,
 			},
 		},
@@ -205,7 +207,7 @@
 
 	expected := request_matches_for_team_response.RequestMatchesForTeamResponseT{
 		MatchList: []*request_matches_for_team_response.MatchT{
-			// MatchNumber, Round, CompLevel
+			// MatchNumber, SetNumber, CompLevel
 			// R1, R2, R3, B1, B2, B3
 			{
 				1, 1, "qual",
@@ -228,7 +230,7 @@
 	db := MockDatabase{
 		stats: []db.Stats{
 			{
-				TeamNumber: 971, MatchNumber: 1,
+				TeamNumber: 971, MatchNumber: 1, SetNumber: 2, CompLevel: "quals",
 				StartingQuadrant: 1,
 				AutoBallPickedUp: [5]bool{true, false, false, false, true},
 				ShotsMissed:      1, UpperGoalShots: 2, LowerGoalShots: 3,
@@ -237,7 +239,7 @@
 				Comment: "a lovely comment", CollectedBy: "john",
 			},
 			{
-				TeamNumber: 972, MatchNumber: 1,
+				TeamNumber: 972, MatchNumber: 1, SetNumber: 4, CompLevel: "extra",
 				StartingQuadrant: 2,
 				AutoBallPickedUp: [5]bool{false, false, true, false, false},
 				ShotsMissed:      2, UpperGoalShots: 3, LowerGoalShots: 4,
@@ -263,7 +265,7 @@
 	expected := request_data_scouting_response.RequestDataScoutingResponseT{
 		StatsList: []*request_data_scouting_response.StatsT{
 			{
-				Team: 971, Match: 1,
+				Team: 971, Match: 1, SetNumber: 2, CompLevel: "quals",
 				MissedShotsAuto: 4, UpperGoalAuto: 5, LowerGoalAuto: 6,
 				MissedShotsTele: 1, UpperGoalTele: 2, LowerGoalTele: 3,
 				DefenseRating:         7,
@@ -276,7 +278,7 @@
 				Comment:          "a lovely comment",
 			},
 			{
-				Team: 972, Match: 1,
+				Team: 972, Match: 1, SetNumber: 4, CompLevel: "extra",
 				MissedShotsAuto: 5, UpperGoalAuto: 6, LowerGoalAuto: 7,
 				MissedShotsTele: 2, UpperGoalTele: 3, LowerGoalTele: 4,
 				DefenseRating:         8,
@@ -360,6 +362,7 @@
 			{
 				CompLevel:   "qual",
 				MatchNumber: 1,
+				SetNumber:   2,
 				Alliances: scraping.Alliances{
 					Red: scraping.Alliance{
 						TeamKeys: []string{
@@ -411,7 +414,7 @@
 	expectedMatches := []db.Match{
 		{
 			MatchNumber: 1,
-			Round:       1,
+			SetNumber:   2,
 			CompLevel:   "qual",
 			R1:          100,
 			R2:          200,
diff --git a/scouting/www/app.ng.html b/scouting/www/app.ng.html
index 3c9c2c2..297fd39 100644
--- a/scouting/www/app.ng.html
+++ b/scouting/www/app.ng.html
@@ -75,6 +75,8 @@
     (switchTabsEvent)="switchTabTo($event)"
     [teamNumber]="selectedTeamInMatch.teamNumber"
     [matchNumber]="selectedTeamInMatch.matchNumber"
+    [setNumber]="selectedTeamInMatch.setNumber"
+    [compLevel]="selectedTeamInMatch.compLevel"
     *ngSwitchCase="'Entry'"
   ></app-entry>
   <frc971-notes *ngSwitchCase="'Notes'"></frc971-notes>
diff --git a/scouting/www/app.ts b/scouting/www/app.ts
index 9d6c539..4f95c90 100644
--- a/scouting/www/app.ts
+++ b/scouting/www/app.ts
@@ -14,6 +14,7 @@
 type TeamInMatch = {
   teamNumber: number;
   matchNumber: number;
+  setNumber: number;
   compLevel: string;
 };
 
@@ -26,6 +27,7 @@
   selectedTeamInMatch: TeamInMatch = {
     teamNumber: 1,
     matchNumber: 1,
+    setNumber: 1,
     compLevel: 'qm',
   };
   tab: Tab = 'MatchList';
diff --git a/scouting/www/entry/entry.component.ts b/scouting/www/entry/entry.component.ts
index c90e3d0..d6829c5 100644
--- a/scouting/www/entry/entry.component.ts
+++ b/scouting/www/entry/entry.component.ts
@@ -25,6 +25,19 @@
   | 'Review and Submit'
   | 'Success';
 
+// TODO(phil): Deduplicate with match_list.component.ts.
+const COMP_LEVELS = ['qm', 'ef', 'qf', 'sf', 'f'] as const;
+type CompLevel = typeof COMP_LEVELS[number];
+
+// TODO(phil): Deduplicate with match_list.component.ts.
+const COMP_LEVEL_LABELS: Record<CompLevel, string> = {
+  qm: 'Qualifications',
+  ef: 'Eighth Finals',
+  qf: 'Quarter Finals',
+  sf: 'Semi Finals',
+  f: 'Finals',
+};
+
 @Component({
   selector: 'app-entry',
   templateUrl: './entry.ng.html',
@@ -34,11 +47,15 @@
   // Re-export the type here so that we can use it in the `[value]` attribute
   // of radio buttons.
   readonly ClimbLevel = ClimbLevel;
+  readonly COMP_LEVELS = COMP_LEVELS;
+  readonly COMP_LEVEL_LABELS = COMP_LEVEL_LABELS;
 
   section: Section = 'Team Selection';
   @Output() switchTabsEvent = new EventEmitter<string>();
   @Input() matchNumber: number = 1;
   @Input() teamNumber: number = 1;
+  @Input() setNumber: number = 1;
+  @Input() compLevel: CompLevel = 'qm';
   autoUpperShotsMade: number = 0;
   autoLowerShotsMade: number = 0;
   autoShotsMissed: number = 0;
@@ -113,10 +130,13 @@
     this.errorMessage = '';
 
     const builder = new Builder();
+    const compLevel = builder.createString(this.compLevel);
     const comment = builder.createString(this.comment);
     SubmitDataScouting.startSubmitDataScouting(builder);
     SubmitDataScouting.addTeam(builder, this.teamNumber);
     SubmitDataScouting.addMatch(builder, this.matchNumber);
+    SubmitDataScouting.addSetNumber(builder, this.setNumber);
+    SubmitDataScouting.addCompLevel(builder, compLevel);
     SubmitDataScouting.addMissedShotsAuto(builder, this.autoShotsMissed);
     SubmitDataScouting.addUpperGoalAuto(builder, this.autoUpperShotsMade);
     SubmitDataScouting.addLowerGoalAuto(builder, this.autoLowerShotsMade);
diff --git a/scouting/www/entry/entry.ng.html b/scouting/www/entry/entry.ng.html
index b8eaa78..6dab39b 100644
--- a/scouting/www/entry/entry.ng.html
+++ b/scouting/www/entry/entry.ng.html
@@ -28,6 +28,24 @@
         max="9999"
       />
     </div>
+    <div class="row">
+      <label for="set_number">Set Number</label>
+      <input
+        [(ngModel)]="setNumber"
+        type="number"
+        id="set_number"
+        min="1"
+        max="10"
+      />
+    </div>
+    <div class="row">
+      <label for="comp_level">Comp Level</label>
+      <select [(ngModel)]="compLevel" type="number" id="comp_level">
+        <option *ngFor="let level of COMP_LEVELS" [ngValue]="level">
+          {{COMP_LEVEL_LABELS[level]}}
+        </option>
+      </select>
+    </div>
     <div class="buttons">
       <!-- hack to right align the next button -->
       <div></div>
@@ -205,10 +223,10 @@
         [(ngModel)]="level"
         type="radio"
         name="level"
-        id="transversal"
-        [value]="ClimbLevel.Transversal"
+        id="traversal"
+        [value]="ClimbLevel.Traversal"
       />
-      <label for="transversal">Transversal</label>
+      <label for="traversal">Traversal</label>
       <br />
       <input
         [(ngModel)]="level"
@@ -348,6 +366,8 @@
     <ul>
       <li>Match number: {{matchNumber}}</li>
       <li>Team number: {{teamNumber}}</li>
+      <li>SetNumber: {{setNumber}}</li>
+      <li>Comp Level: {{COMP_LEVEL_LABELS[compLevel]}}</li>
     </ul>
 
     <h4>Auto</h4>
@@ -372,7 +392,7 @@
 
     <h4>Climb</h4>
     <ul>
-      <li>Level: {{level | levelToString}}</li>
+      <li>Climb Level: {{level | levelToString}}</li>
       <li>Comments: {{comment}}</li>
     </ul>
 
diff --git a/scouting/www/match_list/BUILD b/scouting/www/match_list/BUILD
index a33caf8..10c0a22 100644
--- a/scouting/www/match_list/BUILD
+++ b/scouting/www/match_list/BUILD
@@ -19,6 +19,7 @@
         "//scouting/webserver/requests/messages:error_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_all_matches_response_ts_fbs",
         "//scouting/webserver/requests/messages:request_all_matches_ts_fbs",
+        "//scouting/www/rpc",
         "@com_github_google_flatbuffers//ts:flatbuffers_ts",
         "@npm//@angular/common",
         "@npm//@angular/core",
diff --git a/scouting/www/match_list/match_list.component.ts b/scouting/www/match_list/match_list.component.ts
index b2a2731..ab54e3e 100644
--- a/scouting/www/match_list/match_list.component.ts
+++ b/scouting/www/match_list/match_list.component.ts
@@ -1,5 +1,5 @@
 import {Component, EventEmitter, OnInit, Output} from '@angular/core';
-import {ByteBuffer, Builder} from 'flatbuffers';
+import {Builder, ByteBuffer} from 'flatbuffers';
 import {ErrorResponse} from 'org_frc971/scouting/webserver/requests/messages/error_response_generated';
 import {RequestAllMatches} from 'org_frc971/scouting/webserver/requests/messages/request_all_matches_generated';
 import {
@@ -7,14 +7,15 @@
   RequestAllMatchesResponse,
 } from 'org_frc971/scouting/webserver/requests/messages/request_all_matches_response_generated';
 
+import {MatchListRequestor} from '../rpc/match_list_requestor';
+
 type TeamInMatch = {
   teamNumber: number;
   matchNumber: number;
+  setNumber: number;
   compLevel: string;
 };
 
-const MATCH_TYPE_ORDERING = ['qm', 'ef', 'qf', 'sf', 'f'];
-
 @Component({
   selector: 'app-match-list',
   templateUrl: './match_list.ng.html',
@@ -26,6 +27,8 @@
   errorMessage: string = '';
   matchList: Match[] = [];
 
+  constructor(private readonly matchListRequestor: MatchListRequestor) {}
+
   setTeamInMatch(teamInMatch: TeamInMatch) {
     this.selectedTeamEvent.emit(teamInMatch);
   }
@@ -59,7 +62,9 @@
   }
 
   displayMatchNumber(match: Match): string {
-    return `${this.matchType(match)} ${match.matchNumber()}`;
+    // Only display the set number for eliminations matches.
+    const setNumber = match.compLevel() == 'qm' ? '' : `${match.setNumber()}`;
+    return `${this.matchType(match)} ${setNumber} Match ${match.matchNumber()}`;
   }
 
   ngOnInit() {
@@ -67,62 +72,15 @@
   }
 
   async fetchMatchList() {
+    this.progressMessage = 'Fetching match list. Please be patient.';
     this.errorMessage = '';
 
-    const builder = new Builder();
-    RequestAllMatches.startRequestAllMatches(builder);
-    builder.finish(RequestAllMatches.endRequestAllMatches(builder));
-
-    this.progressMessage = 'Fetching match list. Please be patient.';
-
-    const buffer = builder.asUint8Array();
-    const res = await fetch('/requests/request/all_matches', {
-      method: 'POST',
-      body: buffer,
-    });
-
-    if (res.ok) {
-      const resBuffer = await res.arrayBuffer();
-      const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
-      const parsedResponse =
-        RequestAllMatchesResponse.getRootAsRequestAllMatchesResponse(fbBuffer);
-
-      // Convert the flatbuffer list into an array. That's more useful.
-      this.matchList = [];
-      for (let i = 0; i < parsedResponse.matchListLength(); i++) {
-        this.matchList.push(parsedResponse.matchList(i));
-      }
-
-      // Sort the list so it is in chronological order.
-      this.matchList.sort((a, b) => {
-        const aMatchTypeIndex = MATCH_TYPE_ORDERING.indexOf(a.compLevel());
-        const bMatchTypeIndex = MATCH_TYPE_ORDERING.indexOf(b.compLevel());
-        if (aMatchTypeIndex < bMatchTypeIndex) {
-          return -1;
-        }
-        if (aMatchTypeIndex > bMatchTypeIndex) {
-          return 1;
-        }
-        const aMatchNumber = a.matchNumber();
-        const bMatchNumber = b.matchNumber();
-        if (aMatchNumber < bMatchNumber) {
-          return -1;
-        }
-        if (aMatchNumber > bMatchNumber) {
-          return 1;
-        }
-        return 0;
-      });
-
+    try {
+      this.matchList = await this.matchListRequestor.fetchMatchList();
       this.progressMessage = 'Successfully fetched match list.';
-    } else {
+    } catch (e) {
+      this.errorMessage = e;
       this.progressMessage = '';
-      const resBuffer = await res.arrayBuffer();
-      const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
-      const parsedResponse = ErrorResponse.getRootAsErrorResponse(fbBuffer);
-
-      const errorMessage = parsedResponse.errorMessage();
-      this.errorMessage = `Received ${res.status} ${res.statusText}: "${errorMessage}"`;
     }
   }
 }
diff --git a/scouting/www/match_list/match_list.ng.html b/scouting/www/match_list/match_list.ng.html
index 2f77198..e890faf 100644
--- a/scouting/www/match_list/match_list.ng.html
+++ b/scouting/www/match_list/match_list.ng.html
@@ -13,6 +13,7 @@
         (click)="setTeamInMatch({
             teamNumber: team.teamNumber,
             matchNumber: match.matchNumber(),
+            setNumber: match.setNumber(),
             compLevel: match.compLevel()
             })"
         class="match-item"
diff --git a/scouting/www/rpc/BUILD b/scouting/www/rpc/BUILD
new file mode 100644
index 0000000..581a7b8
--- /dev/null
+++ b/scouting/www/rpc/BUILD
@@ -0,0 +1,19 @@
+load("@npm//@bazel/typescript:index.bzl", "ts_library")
+
+ts_library(
+    name = "rpc",
+    srcs = [
+        "match_list_requestor.ts",
+    ],
+    compiler = "//tools:tsc_wrapped_with_angular",
+    target_compatible_with = ["@platforms//cpu:x86_64"],
+    use_angular_plugin = True,
+    visibility = ["//visibility:public"],
+    deps = [
+        "//scouting/webserver/requests/messages:error_response_ts_fbs",
+        "//scouting/webserver/requests/messages:request_all_matches_response_ts_fbs",
+        "//scouting/webserver/requests/messages:request_all_matches_ts_fbs",
+        "@com_github_google_flatbuffers//ts:flatbuffers_ts",
+        "@npm//@angular/core",
+    ],
+)
diff --git a/scouting/www/rpc/match_list_requestor.ts b/scouting/www/rpc/match_list_requestor.ts
new file mode 100644
index 0000000..f97b1ca
--- /dev/null
+++ b/scouting/www/rpc/match_list_requestor.ts
@@ -0,0 +1,83 @@
+import {Injectable} from '@angular/core';
+import {Builder, ByteBuffer} from 'flatbuffers';
+import {ErrorResponse} from 'org_frc971/scouting/webserver/requests/messages/error_response_generated';
+import {RequestAllMatches} from 'org_frc971/scouting/webserver/requests/messages/request_all_matches_generated';
+import {
+  Match,
+  RequestAllMatchesResponse,
+} from 'org_frc971/scouting/webserver/requests/messages/request_all_matches_response_generated';
+
+const MATCH_TYPE_ORDERING = ['qm', 'ef', 'qf', 'sf', 'f'];
+
+@Injectable({providedIn: 'root'})
+export class MatchListRequestor {
+  async fetchMatchList(): Promise<Match[]> {
+    const builder = new Builder();
+    RequestAllMatches.startRequestAllMatches(builder);
+    builder.finish(RequestAllMatches.endRequestAllMatches(builder));
+
+    const buffer = builder.asUint8Array();
+    const res = await fetch('/requests/request/all_matches', {
+      method: 'POST',
+      body: buffer,
+    });
+
+    if (res.ok) {
+      const resBuffer = await res.arrayBuffer();
+      const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
+      const parsedResponse =
+        RequestAllMatchesResponse.getRootAsRequestAllMatchesResponse(fbBuffer);
+
+      // Convert the flatbuffer list into an array. That's more useful.
+      const matchList = [];
+      for (let i = 0; i < parsedResponse.matchListLength(); i++) {
+        matchList.push(parsedResponse.matchList(i));
+      }
+
+      // Sort the list so it is in chronological order.
+      matchList.sort((a, b) => {
+        // First sort by match type. E.g. finals are last.
+        const aMatchTypeIndex = MATCH_TYPE_ORDERING.indexOf(a.compLevel());
+        const bMatchTypeIndex = MATCH_TYPE_ORDERING.indexOf(b.compLevel());
+        if (aMatchTypeIndex < bMatchTypeIndex) {
+          return -1;
+        }
+        if (aMatchTypeIndex > bMatchTypeIndex) {
+          return 1;
+        }
+        // Then sort by match number. E.g. in semi finals, all match 1 rounds
+        // are done first. Then come match 2 rounds. And then, if necessary,
+        // the match 3 rounds.
+        const aMatchNumber = a.matchNumber();
+        const bMatchNumber = b.matchNumber();
+        if (aMatchNumber < bMatchNumber) {
+          return -1;
+        }
+        if (aMatchNumber > bMatchNumber) {
+          return 1;
+        }
+        // Lastly, sort by set number. I.e. Semi Final 1 Match 1 happens first.
+        // Then comes Semi Final 2 Match 1. Then comes Semi Final 1 Match 2. Then
+        // Semi Final 2 Match 2.
+        const aSetNumber = a.setNumber();
+        const bSetNumber = b.setNumber();
+        if (aSetNumber < bSetNumber) {
+          return -1;
+        }
+        if (aSetNumber > bSetNumber) {
+          return 1;
+        }
+        return 0;
+      });
+
+      return matchList;
+    } else {
+      const resBuffer = await res.arrayBuffer();
+      const fbBuffer = new ByteBuffer(new Uint8Array(resBuffer));
+      const parsedResponse = ErrorResponse.getRootAsErrorResponse(fbBuffer);
+
+      const errorMessage = parsedResponse.errorMessage();
+      throw `Received ${res.status} ${res.statusText}: "${errorMessage}"`;
+    }
+  }
+}
diff --git a/y2020/www/field_handler.ts b/y2020/www/field_handler.ts
index c165bcc..28c35aa 100644
--- a/y2020/www/field_handler.ts
+++ b/y2020/www/field_handler.ts
@@ -1,10 +1,9 @@
 import {ByteBuffer} from 'flatbuffers';
-import {Connection} from 'org_frc971/aos/network/www/proxy';
-import * as flatbuffers_builder from 'org_frc971/external/com_github_google_flatbuffers/ts/builder';
-import {Status as DrivetrainStatus} from 'org_frc971/frc971/control_loops/drivetrain/drivetrain_status_generated';
-import {LocalizerDebug, RejectionReason, ImageMatchDebug} from 'org_frc971/y2020/control_loops/drivetrain/localizer_debug_generated';
-import {Status as SuperstructureStatus, FlywheelControllerStatus} from 'org_frc971/y2020/control_loops/superstructure/superstructure_status_generated'
-import {ImageMatchResult} from 'org_frc971/y2020/vision/sift/sift_generated';
+import {Connection} from '../../aos/network/www/proxy';
+import {Status as DrivetrainStatus} from '../../frc971/control_loops/drivetrain/drivetrain_status_generated';
+import {LocalizerDebug, RejectionReason, ImageMatchDebug} from '../control_loops/drivetrain/localizer_debug_generated';
+import {Status as SuperstructureStatus, FlywheelControllerStatus} from '../control_loops/superstructure/superstructure_status_generated'
+import {ImageMatchResult} from '../vision/sift/sift_generated';
 
 import {FIELD_LENGTH, FIELD_WIDTH, FT_TO_M, IN_TO_M} from './constants';
 
diff --git a/y2022/actors/auto_splines.cc b/y2022/actors/auto_splines.cc
index 9dd1860..e674dc4 100644
--- a/y2022/actors/auto_splines.cc
+++ b/y2022/actors/auto_splines.cc
@@ -80,5 +80,25 @@
       alliance);
 }
 
+flatbuffers::Offset<frc971::MultiSpline> AutonomousSplines::SplineTwoBall1(
+    aos::Sender<frc971::control_loops::drivetrain::SplineGoal>::Builder
+        *builder,
+    aos::Alliance alliance) {
+  return FixSpline(
+      builder,
+      aos::CopyFlatBuffer<frc971::MultiSpline>(spline_two1_, builder->fbb()),
+      alliance);
+}
+
+flatbuffers::Offset<frc971::MultiSpline> AutonomousSplines::SplineTwoBall2(
+    aos::Sender<frc971::control_loops::drivetrain::SplineGoal>::Builder
+        *builder,
+    aos::Alliance alliance) {
+  return FixSpline(
+      builder,
+      aos::CopyFlatBuffer<frc971::MultiSpline>(spline_two2_, builder->fbb()),
+      alliance);
+}
+
 }  // namespace actors
 }  // namespace y2022
diff --git a/y2022/actors/auto_splines.h b/y2022/actors/auto_splines.h
index 546710c..2f5c399 100644
--- a/y2022/actors/auto_splines.h
+++ b/y2022/actors/auto_splines.h
@@ -26,7 +26,11 @@
         spline_2_(aos::JsonFileToFlatbuffer<frc971::MultiSpline>(
             "splines/spline_5_ball_2.json")),
         spline_3_(aos::JsonFileToFlatbuffer<frc971::MultiSpline>(
-            "splines/spline_5_ball_3.json")){}
+            "splines/spline_5_ball_3.json")),
+        spline_two1_(aos::JsonFileToFlatbuffer<frc971::MultiSpline>(
+            "splines/spline_2_ball.json")),
+        spline_two2_(aos::JsonFileToFlatbuffer<frc971::MultiSpline>(
+            "splines/spline_2_ball_2.json")) {}
 
   static flatbuffers::Offset<frc971::MultiSpline> BasicSSpline(
       aos::Sender<frc971::control_loops::drivetrain::Goal>::Builder *builder);
@@ -51,11 +55,22 @@
           *builder,
       aos::Alliance alliance);
 
+  flatbuffers::Offset<frc971::MultiSpline> SplineTwoBall1(
+      aos::Sender<frc971::control_loops::drivetrain::SplineGoal>::Builder
+          *builder,
+      aos::Alliance alliance);
+  flatbuffers::Offset<frc971::MultiSpline> SplineTwoBall2(
+      aos::Sender<frc971::control_loops::drivetrain::SplineGoal>::Builder
+          *builder,
+      aos::Alliance alliance);
+
  private:
   aos::FlatbufferDetachedBuffer<frc971::MultiSpline> test_spline_;
   aos::FlatbufferDetachedBuffer<frc971::MultiSpline> spline_1_;
   aos::FlatbufferDetachedBuffer<frc971::MultiSpline> spline_2_;
   aos::FlatbufferDetachedBuffer<frc971::MultiSpline> spline_3_;
+  aos::FlatbufferDetachedBuffer<frc971::MultiSpline> spline_two1_;
+  aos::FlatbufferDetachedBuffer<frc971::MultiSpline> spline_two2_;
 };
 
 }  // namespace actors
diff --git a/y2022/actors/autonomous_actor.cc b/y2022/actors/autonomous_actor.cc
index a8a2905..4f49f81 100644
--- a/y2022/actors/autonomous_actor.cc
+++ b/y2022/actors/autonomous_actor.cc
@@ -13,13 +13,15 @@
 #include "y2022/control_loops/drivetrain/drivetrain_base.h"
 
 DEFINE_bool(spline_auto, false, "If true, define a spline autonomous mode");
-DEFINE_bool(rapid_react, true,
+DEFINE_bool(rapid_react, false,
             "If true, run the main rapid react autonomous mode");
+DEFINE_bool(rapid_react_two, true,
+            "If true, run the two ball rapid react autonomous mode");
 
 namespace y2022 {
 namespace actors {
 namespace {
-constexpr double kExtendIntakeGoal = -0.02;
+constexpr double kExtendIntakeGoal = -0.10;
 constexpr double kRetractIntakeGoal = 1.47;
 constexpr double kIntakeRollerVoltage = 12.0;
 constexpr double kRollerVoltage = 12.0;
@@ -107,12 +109,22 @@
                    SplineDirection::kBackward),
         PlanSpline(std::bind(&AutonomousSplines::Spline2, &auto_splines_,
                              std::placeholders::_1, alliance_),
-                   SplineDirection::kForward),
+                   SplineDirection::kBackward),
         PlanSpline(std::bind(&AutonomousSplines::Spline3, &auto_splines_,
                              std::placeholders::_1, alliance_),
-                   SplineDirection::kBackward)};
+                   SplineDirection::kForward)};
     starting_position_ = rapid_react_splines_.value()[0].starting_position();
     CHECK(starting_position_);
+  } else if (FLAGS_rapid_react_two) {
+    rapid_react_two_spline_ = {
+        PlanSpline(std::bind(&AutonomousSplines::SplineTwoBall1, &auto_splines_,
+                             std::placeholders::_1, alliance_),
+                   SplineDirection::kBackward),
+        PlanSpline(std::bind(&AutonomousSplines::SplineTwoBall2, &auto_splines_,
+                             std::placeholders::_1, alliance_),
+                   SplineDirection::kForward)};
+    starting_position_ = rapid_react_two_spline_.value()[0].starting_position();
+    CHECK(starting_position_);
   }
 
   is_planned_ = true;
@@ -163,6 +175,8 @@
     SplineAuto();
   } else if (FLAGS_rapid_react) {
     RapidReact();
+  } else if (FLAGS_rapid_react_two) {
+    RapidReactTwo();
   }
 
   return true;
@@ -208,8 +222,7 @@
   // Tell the superstructure a ball was preloaded
   if (!WaitForPreloaded()) return;
 
-  // Fire preloaded ball
-  set_turret_goal(constants::Values::kTurretBackIntakePos());
+  // Fire preloaded ball while driving
   set_fire_at_will(true);
   SendSuperstructureGoal();
   if (!WaitForBallsShot()) return;
@@ -221,33 +234,44 @@
   set_fire_at_will(false);
   SendSuperstructureGoal();
 
-  // Drive and intake the 2 balls in nearest to the starting zonei
-  set_turret_goal(constants::Values::kTurretFrontIntakePos());
+  // Drive and intake the ball nearest to the starting zone.
+  // Fire while moving.
   ExtendBackIntake();
   if (!splines[0].WaitForPlan()) return;
   splines[0].Start();
-  if (!splines[0].WaitForSplineDistanceRemaining(0.02)) return;
+  // Distance before we don't shoot while moving.
+  if (!splines[0].WaitForSplineDistanceRemaining(0.25)) return;
 
-  // Fire the two balls once we stopped
-  RetractBackIntake();
   set_fire_at_will(true);
   SendSuperstructureGoal();
+
+  if (!splines[0].WaitForSplineDistanceRemaining(0.02)) return;
+
+  std::this_thread::sleep_for(std::chrono::milliseconds(1000));
+
+  // Fire the last ball we picked up when stopped.
+  SendSuperstructureGoal();
+  LOG(INFO) << "Close";
   if (!WaitForBallsShot()) return;
   LOG(INFO) << "Shot first 3 balls "
             << chrono::duration<double>(aos::monotonic_clock::now() -
                                         start_time)
                    .count()
             << 's';
-  set_fire_at_will(false);
-  SendSuperstructureGoal();
 
   // Drive to the human player station while intaking two balls.
   // Once is already placed down,
   // and one will be rolled to the robot by the human player
-  ExtendFrontIntake();
   if (!splines[1].WaitForPlan()) return;
   splines[1].Start();
+
+  std::this_thread::sleep_for(std::chrono::milliseconds(1500));
+
+  set_fire_at_will(false);
+  SendSuperstructureGoal();
+
   if (!splines[1].WaitForSplineDistanceRemaining(0.02)) return;
+  std::this_thread::sleep_for(std::chrono::milliseconds(500));
   LOG(INFO) << "At balls 4/5 "
             << chrono::duration<double>(aos::monotonic_clock::now() -
                                         start_time)
@@ -281,6 +305,53 @@
             << 's';
 }
 
+// Rapid React Two Ball Autonomous.
+void AutonomousActor::RapidReactTwo() {
+  aos::monotonic_clock::time_point start_time = aos::monotonic_clock::now();
+
+  CHECK(rapid_react_two_spline_);
+
+  auto &splines = *rapid_react_two_spline_;
+
+  // Tell the superstructure a ball was preloaded
+  if (!WaitForPreloaded()) return;
+  set_fire_at_will(true);
+  SendSuperstructureGoal();
+  if (!WaitForBallsShot()) return;
+  LOG(INFO) << "Shot first ball "
+            << chrono::duration<double>(aos::monotonic_clock::now() -
+                                        start_time)
+                   .count()
+            << 's';
+  set_fire_at_will(false);
+  SendSuperstructureGoal();
+
+  ExtendBackIntake();
+  if (!splines[0].WaitForPlan()) return;
+  splines[0].Start();
+  if (!splines[0].WaitForSplineDistanceRemaining(0.02)) return;
+
+  std::this_thread::sleep_for(std::chrono::milliseconds(300));
+
+  if (!splines[1].WaitForPlan()) return;
+  splines[1].Start();
+  if (!splines[1].WaitForSplineDistanceRemaining(0.02)) return;
+  std::this_thread::sleep_for(std::chrono::milliseconds(500));
+
+  // Fire the ball once we stopped
+  set_fire_at_will(true);
+  SendSuperstructureGoal();
+  if (!WaitForBallsShot()) return;
+  LOG(INFO) << "Shot last ball "
+            << chrono::duration<double>(aos::monotonic_clock::now() -
+                                        start_time)
+                   .count()
+            << 's';
+  set_fire_at_will(false);
+  RetractBackIntake();
+  SendSuperstructureGoal();
+}
+
 [[nodiscard]] bool AutonomousActor::WaitForPreloaded() {
   set_preloaded(true);
   SendSuperstructureGoal();
@@ -323,11 +394,6 @@
           CreateProfileParameters(*builder.fbb(), 20.0, 60.0));
 
   flatbuffers::Offset<StaticZeroingSingleDOFProfiledSubsystemGoal>
-      turret_offset = CreateStaticZeroingSingleDOFProfiledSubsystemGoal(
-          *builder.fbb(), turret_goal_,
-          CreateProfileParameters(*builder.fbb(), 12.0, 20.0));
-
-  flatbuffers::Offset<StaticZeroingSingleDOFProfiledSubsystemGoal>
       catapult_return_position_offset =
           CreateStaticZeroingSingleDOFProfiledSubsystemGoal(
               *builder.fbb(), kCatapultReturnPosition,
@@ -352,7 +418,6 @@
     superstructure_builder.add_turret_intake(*requested_intake_);
   }
   superstructure_builder.add_transfer_roller_speed(transfer_roller_voltage_);
-  superstructure_builder.add_turret(turret_offset);
   superstructure_builder.add_catapult(catapult_goal_offset);
   superstructure_builder.add_fire(fire_);
   superstructure_builder.add_preloaded(preloaded_);
@@ -400,31 +465,12 @@
   superstructure_status_fetcher_.Fetch();
   CHECK(superstructure_status_fetcher_.get());
 
-  // Don't do anything if we aren't loaded
-  if (superstructure_status_fetcher_->state() !=
-          control_loops::superstructure::SuperstructureState::LOADED &&
-      superstructure_status_fetcher_->state() !=
-          control_loops::superstructure::SuperstructureState::SHOOTING) {
-    LOG(WARNING) << "No balls to shoot";
-    return true;
-  }
-
-  // Since we're loaded, there will atleast be 1 ball to shoot
-  int num_wanted = 1;
-
-  // If we have another ball, we will shoot 2
-  if (superstructure_status_fetcher_->front_intake_has_ball() ||
-      superstructure_status_fetcher_->back_intake_has_ball()) {
-    num_wanted++;
-  }
-
   ::aos::time::PhasedLoop phased_loop(frc971::controls::kLoopFrequency,
                                       event_loop()->monotonic_now(),
                                       ActorBase::kLoopOffset);
   superstructure_status_fetcher_.Fetch();
   CHECK(superstructure_status_fetcher_.get() != nullptr);
-  int initial_balls = superstructure_status_fetcher_->shot_count();
-  LOG(INFO) << "Waiting for balls, started with " << initial_balls;
+
   while (true) {
     if (ShouldCancel()) {
       return false;
@@ -432,8 +478,11 @@
     phased_loop.SleepUntilNext();
     superstructure_status_fetcher_.Fetch();
     CHECK(superstructure_status_fetcher_.get() != nullptr);
-    if (superstructure_status_fetcher_->shot_count() - initial_balls >=
-        num_wanted) {
+
+    if (!superstructure_status_fetcher_->front_intake_has_ball() &&
+        !superstructure_status_fetcher_->back_intake_has_ball() &&
+        superstructure_status_fetcher_->state() ==
+            control_loops::superstructure::SuperstructureState::IDLE) {
       return true;
     }
   }
diff --git a/y2022/actors/autonomous_actor.h b/y2022/actors/autonomous_actor.h
index ec66fb3..b36e010 100644
--- a/y2022/actors/autonomous_actor.h
+++ b/y2022/actors/autonomous_actor.h
@@ -47,7 +47,6 @@
   void set_requested_intake(std::optional<RequestedIntake> requested_intake) {
     requested_intake_ = requested_intake;
   }
-  void set_turret_goal(double turret_goal) { turret_goal_ = turret_goal; }
 
   void set_fire_at_will(bool fire) { fire_ = fire; }
   void set_preloaded(bool preloaded) { preloaded_ = preloaded; }
@@ -68,6 +67,7 @@
 
   void SplineAuto();
   void RapidReact();
+  void RapidReactTwo();
 
   void Replan();
 
@@ -77,7 +77,6 @@
   double roller_back_voltage_ = 0.0;
   double transfer_roller_voltage_ = 0.0;
   std::optional<RequestedIntake> requested_intake_ = std::nullopt;
-  double turret_goal_ = 0.0;
   bool fire_ = false;
   bool preloaded_ = false;
 
@@ -95,6 +94,7 @@
 
   std::optional<SplineHandle> test_spline_;
   std::optional<std::array<SplineHandle, 3>> rapid_react_splines_;
+  std::optional<std::array<SplineHandle, 2>> rapid_react_two_spline_;
 
   aos::Alliance alliance_ = aos::Alliance::kInvalid;
   AutonomousSplines auto_splines_;
diff --git a/y2022/actors/splines/spline_2_ball.json b/y2022/actors/splines/spline_2_ball.json
new file mode 100644
index 0000000..bf7e8be
--- /dev/null
+++ b/y2022/actors/splines/spline_2_ball.json
@@ -0,0 +1 @@
+{"spline_count": 1, "spline_x": [1.6215960591132994, 2.0441299469203753, 2.136217765021257, 2.309583831291792, 2.6666670621941027, 2.9165660756303344], "spline_y": [-1.5742995073891626, -1.9839794486639044, -1.8796132548162379, -2.130555402472591, -2.15722202774562, -2.1959456607795707], "constraints": [{"constraint_type": "LONGITUDINAL_ACCELERATION", "value": 3}, {"constraint_type": "LATERAL_ACCELERATION", "value": 2}, {"constraint_type": "VOLTAGE", "value": 10.0}]}
\ No newline at end of file
diff --git a/y2022/actors/splines/spline_5_ball_1.json b/y2022/actors/splines/spline_5_ball_1.json
index da3e4cf..46fddbb 100644
--- a/y2022/actors/splines/spline_5_ball_1.json
+++ b/y2022/actors/splines/spline_5_ball_1.json
@@ -1 +1 @@
-{"spline_count": 1, "spline_x": [-0.18145693702491972, -0.1806686149879133, -0.05595918014581436, 5.762204620882601, 2.7805678460726355, 1.6146169804687496], "spline_y": [2.346189480782648, 3.6925675615333544, 4.41262134323365, 2.4753395126953124, 2.2341888067461992, 1.3005395681218328], "constraints": [{"constraint_type": "LONGITUDINAL_ACCELERATION", "value": 3.0}, {"constraint_type": "LATERAL_ACCELERATION", "value": 2.5}, {"constraint_type": "VOLTAGE", "value": 12.0}, {"constraint_type": "VELOCITY", "value": 0.8, "start_distance": 1.0, "end_distance": 1.15}]}
\ No newline at end of file
+{"spline_count": 1, "spline_x": [0.009329753853116074, -0.014583556392633312, 1.086141950245409, 1.3463506181539948, 1.8252560302734366, 2.7940085985321357], "spline_y": [2.2499321755598816, 3.695204931543886, 3.9907963594941256, 3.2671894020316525, 2.3428532547468994, 2.267936657588998], "constraints": [{"constraint_type": "LONGITUDINAL_ACCELERATION", "value": 3.0}, {"constraint_type": "LATERAL_ACCELERATION", "value": 2.5}, {"constraint_type": "VOLTAGE", "value": 12.0}, {"constraint_type": "VELOCITY", "value": 0.5, "start_distance": 1.0, "end_distance": 1.20}, {"constraint_type": "VELOCITY", "value": 1.0, "start_distance": 2.8, "end_distance": 10}]}
diff --git a/y2022/actors/splines/spline_5_ball_2.json b/y2022/actors/splines/spline_5_ball_2.json
index 3efd1ee..3ccf4d4 100644
--- a/y2022/actors/splines/spline_5_ball_2.json
+++ b/y2022/actors/splines/spline_5_ball_2.json
@@ -1 +1 @@
-{"spline_count": 1, "spline_x": [1.6037446032516893, 2.2055167265625, 2.8212725389450344, 6.148134261553881, 5.92062789622044, 6.7046250148859805], "spline_y": [1.2861465107685808, 1.7993420469805743, 1.286805497714088, 2.0935212995201415, 1.9849658141364017, 2.755576908889358], "constraints": [{"constraint_type": "LONGITUDINAL_ACCELERATION", "value": 3.0}, {"constraint_type": "LATERAL_ACCELERATION", "value": 3.0}, {"constraint_type": "VOLTAGE", "value": 12.0}]}
\ No newline at end of file
+{"spline_count": 1, "spline_x": [2.8029000394337356, 3.598701010385372, 3.592928864987311, 5.716241105891047, 6.058409698241228, 6.836921351984797], "spline_y": [2.2659592486204163, 2.2235586475607194, 1.3732945972518653, 1.187231336623733, 1.965522252857657, 2.7153394202517944], "constraints": [{"constraint_type": "LONGITUDINAL_ACCELERATION", "value": 3.0}, {"constraint_type": "LATERAL_ACCELERATION", "value": 3.0}, {"constraint_type": "VOLTAGE", "value": 12.0}]}
\ No newline at end of file
diff --git a/y2022/actors/splines/spline_5_ball_3.json b/y2022/actors/splines/spline_5_ball_3.json
index 6b239fc..7c288ca 100644
--- a/y2022/actors/splines/spline_5_ball_3.json
+++ b/y2022/actors/splines/spline_5_ball_3.json
@@ -1 +1 @@
-{"spline_count": 1, "spline_x": [6.702231375950168, 6.373881457031249, 5.758688966174009, 3.1788453508620487, 2.273453592205448, 1.6114305300886826], "spline_y": [2.7438724869219806, 2.4293757261929896, 2.0768880836927197, 1.7809922274871859, 1.852294682145808, 1.2821724076488596], "constraints": [{"constraint_type": "LONGITUDINAL_ACCELERATION", "value": 4.0}, {"constraint_type": "LATERAL_ACCELERATION", "value": 3}, {"constraint_type": "VOLTAGE", "value": 12.0}]}
\ No newline at end of file
+{"spline_count": 1, "spline_x": [6.853097963576857, 6.385098954905726, 4.82969339985708, 2.83784620575764, 1.5541577889609748, 1.0363839414488067], "spline_y": [2.714615301031989, 2.2383835427652135, 1.708398624422522, 2.517585062352501, 2.5751182100225627, 2.1689745582854774], "constraints": [{"constraint_type": "LONGITUDINAL_ACCELERATION", "value": 4.0}, {"constraint_type": "LATERAL_ACCELERATION", "value": 3.0}, {"constraint_type": "VOLTAGE", "value": 12.0}]}
\ No newline at end of file
diff --git a/y2022/constants.cc b/y2022/constants.cc
index a6b9a28..3fbe27b 100644
--- a/y2022/constants.cc
+++ b/y2022/constants.cc
@@ -34,7 +34,6 @@
 
   Values r;
 
-  // TODO(Yash): Set constants
   // Intake constants.
   auto *const intake_front = &r.intake_front;
   auto *const intake_back = &r.intake_back;
@@ -62,16 +61,24 @@
   intake_front->subsystem_params = intake_params;
   intake_back->subsystem_params = intake_params;
 
-  // TODO(Yash): Set constants
   // Turret constants.
   auto *const turret = &r.turret;
   auto *const turret_params = &turret->subsystem_params;
+  auto *turret_range = &r.turret_range;
+
+  *turret_range = ::frc971::constants::Range{
+      .lower_hard = -7.0,  // Back Hard
+      .upper_hard = 3.4,   // Front Hard
+      .lower = -6.5,       // Back Soft
+      .upper = 3.15        // Front Soft
+  };
 
   turret_params->zeroing_voltage = 4.0;
   turret_params->operating_voltage = 12.0;
   turret_params->zeroing_profile_params = {0.5, 2.0};
+  turret_params->default_profile_params = {10.0, 20.0};
   turret_params->default_profile_params = {15.0, 20.0};
-  turret_params->range = Values::kTurretRange();
+  turret_params->range = *turret_range;
   turret_params->make_integral_loop =
       control_loops::superstructure::turret::MakeIntegralTurretLoop;
   turret_params->zeroing_constants.average_filter_size =
@@ -132,21 +139,23 @@
 
   // Interpolation table for comp and practice robots
   r.shot_interpolation_table = InterpolationTable<Values::ShotParams>({
-      {1.0, {0.0, 19.0}},
-      {1.6, {0.0, 19.0}},
-      {1.9, {0.1, 19.0}},
-      {2.12, {0.15, 18.8}},
-      {2.9, {0.25, 19.2}},
-      {3.2, {0.28, 20.5}},
+      {1.0, {0.05, 19.4}},
+      {1.6, {0.05, 19.4}},
+      {1.9, {0.1, 19.4}},
+      {2.12, {0.13, 19.4}},
+      {2.9, {0.24, 19.9}},
 
-      {3.86, {0.35, 20.9}},
-      {4.9, {0.4, 21.9}},
+      {3.2, {0.26, 20.7}},
+
+      {3.60, {0.33, 20.9}},
+      {4.50, {0.38, 22.5}},
+      {4.9, {0.4, 22.9}},
       {5.4, {0.4, 23.9}},
-      {6.0, {0.40, 25.0}},
-      {7.0, {0.37, 27.1}},
 
-      {7.8, {0.35, 28.0}},
-      {10.0, {0.35, 28.0}},
+      {6.0, {0.40, 25.4}},
+      {7.0, {0.37, 28.1}},
+
+      {10.0, {0.37, 28.1}},
   });
 
   if (false) {
@@ -207,24 +216,26 @@
     case kCompTeamNumber:
       climber->potentiometer_offset = -0.0463847608752 - 0.0376876182111 +
                                       0.0629263851579 - 0.00682128836400001 +
-                                      0.0172237531191;
+                                      0.0172237531191 - 0.0172237531191;
 
       intake_front->potentiometer_offset =
-          2.79628370453323 - 0.0250288114832881 + 0.577152542437606;
+          2.79628370453323 - 0.0250288114832881 + 0.577152542437606 + 0.476513825677792;
       intake_front->subsystem_params.zeroing_constants
-          .measured_absolute_position = 0.26963366701647;
+          .measured_absolute_position = 0.205422145836751;
 
       intake_back->potentiometer_offset =
-          3.1409576474047 + 0.278653334013286 + 0.00879137908308503;
+          3.1409576474047 + 0.278653334013286 + 0.00879137908308503 +
+          0.0837134053818833 + 0.832945730100298 - 0.00759895654985426 - 2.03114758819475;
       intake_back->subsystem_params.zeroing_constants
-          .measured_absolute_position = 0.242434593996789;
+          .measured_absolute_position = 0.352050723370449;
 
-      turret->potentiometer_offset = -9.99970387166721 + 0.06415943 +
-                                     0.073290115367682 - 0.0634440443622909 +
-                                     0.213601224728352 + 0.0657973101027296 -
-                                     0.114726411377978;
+      turret->potentiometer_offset =
+          -9.99970387166721 + 0.06415943 + 0.073290115367682 -
+          0.0634440443622909 + 0.213601224728352 + 0.0657973101027296 -
+          0.114726411377978 - 0.980314029089968 - 0.0266013159299456 +
+          0.0631240002215899 + 0.222882504808653;
       turret->subsystem_params.zeroing_constants.measured_absolute_position =
-          0.39190961531060;
+          1.14081767944401;
 
       flipper_arm_left->potentiometer_offset = -6.4;
       flipper_arm_right->potentiometer_offset = 5.56;
@@ -235,21 +246,26 @@
       break;
 
     case kPracticeTeamNumber:
+      // TODO(milind): calibrate once mounted
       climber->potentiometer_offset = 0.0;
-      intake_front->potentiometer_offset = 0.0;
+      intake_front->potentiometer_offset = 3.06604378582351;
       intake_front->subsystem_params.zeroing_constants
-          .measured_absolute_position = 0.0;
-      intake_back->potentiometer_offset = 0.0;
+          .measured_absolute_position = 0.318042402595181;
+      intake_back->potentiometer_offset = 3.10861174832838;
       intake_back->subsystem_params.zeroing_constants
-          .measured_absolute_position = 0.0;
-      turret->potentiometer_offset = 0.0;
+          .measured_absolute_position = 0.140554083520329;
+      turret->potentiometer_offset =
+          -8.14418207451834 + 0.342635491808218 - 0.944807955598189;
       turret->subsystem_params.zeroing_constants.measured_absolute_position =
-          0.0;
-      flipper_arm_left->potentiometer_offset = 0.0;
-      flipper_arm_right->potentiometer_offset = 0.0;
+          0.524976896930003;
+      turret_range->upper = 3.0;
+      turret_params->range = *turret_range;
+      flipper_arm_left->potentiometer_offset = -4.39536583413615;
+      flipper_arm_right->potentiometer_offset = 4.36264091401229;
 
-      catapult_params->zeroing_constants.measured_absolute_position = 0.0;
-      catapult->potentiometer_offset = 0.0;
+      catapult_params->zeroing_constants.measured_absolute_position =
+          1.62909518684227;
+      catapult->potentiometer_offset = -1.52951814169821 - 0.0200812009850977;
       break;
 
     case kCodingRobotTeamNumber:
diff --git a/y2022/constants.h b/y2022/constants.h
index 4b5351e..f410014 100644
--- a/y2022/constants.h
+++ b/y2022/constants.h
@@ -44,8 +44,10 @@
 
   // Climber
   static constexpr ::frc971::constants::Range kClimberRange() {
-    return ::frc971::constants::Range{
-        .lower_hard = -0.01, .upper_hard = 0.59, .lower = 0.003, .upper = 0.555};
+    return ::frc971::constants::Range{.lower_hard = -0.01,
+                                      .upper_hard = 0.59,
+                                      .lower = 0.003,
+                                      .upper = 0.555};
   }
   static constexpr double kClimberPotMetersPerRevolution() {
     return 22 * 0.25 * 0.0254;
@@ -109,16 +111,7 @@
 
   // Turret
   PotAndAbsEncoderConstants turret;
-
-  // TODO (Yash): Constants need to be tuned
-  static constexpr ::frc971::constants::Range kTurretRange() {
-    return ::frc971::constants::Range{
-        .lower_hard = -7.0,  // Back Hard
-        .upper_hard = 3.4,   // Front Hard
-        .lower = -6.5,       // Back Soft
-        .upper = 3.15        // Front Soft
-    };
-  }
+  frc971::constants::Range turret_range;
 
   static constexpr double kTurretBackIntakePos() { return -M_PI; }
   static constexpr double kTurretFrontIntakePos() { return 0; }
diff --git a/y2022/control_loops/drivetrain/localizer.cc b/y2022/control_loops/drivetrain/localizer.cc
index 65df654..79ced2d 100644
--- a/y2022/control_loops/drivetrain/localizer.cc
+++ b/y2022/control_loops/drivetrain/localizer.cc
@@ -48,7 +48,7 @@
       joystick_state_fetcher_->autonomous()) {
     // TODO(james): This is an inelegant way to avoid having the localizer mess
     // up splines. Do better.
-    return;
+    //return;
   }
   if (localizer_output_fetcher_.Fetch()) {
     clock_offset_fetcher_.Fetch();
diff --git a/y2022/control_loops/python/turret.py b/y2022/control_loops/python/turret.py
index 83ba4b3..fead853 100644
--- a/y2022/control_loops/python/turret.py
+++ b/y2022/control_loops/python/turret.py
@@ -17,18 +17,18 @@
 except gflags.DuplicateFlagError:
     pass
 
-kTurret = angular_system.AngularSystemParams(
-    name='Turret',
-    motor=control_loop.Falcon(),
-    G=0.01,
-    J=2.0,
-    q_pos=0.40,
-    q_vel=20.0,
-    kalman_q_pos=0.12,
-    kalman_q_vel=2.0,
-    kalman_q_voltage=4.0,
-    kalman_r_position=0.05,
-    radius=24 * 0.0254)
+kTurret = angular_system.AngularSystemParams(name='Turret',
+                                             motor=control_loop.Falcon(),
+                                             G=(14.0 / 66.0) * (24.0 / 58.0) *
+                                             (18.0 / 110.0),
+                                             J=2.0,
+                                             q_pos=0.40,
+                                             q_vel=20.0,
+                                             kalman_q_pos=0.12,
+                                             kalman_q_vel=2.0,
+                                             kalman_q_voltage=4.0,
+                                             kalman_r_position=0.05,
+                                             radius=24 * 0.0254)
 
 
 def main(argv):
diff --git a/y2022/control_loops/superstructure/BUILD b/y2022/control_loops/superstructure/BUILD
index ebd9ace..fabc871 100644
--- a/y2022/control_loops/superstructure/BUILD
+++ b/y2022/control_loops/superstructure/BUILD
@@ -88,6 +88,7 @@
         "//y2022:constants",
         "//y2022/control_loops/superstructure/catapult",
         "//y2022/control_loops/superstructure/turret:aiming",
+        "//y2022/vision:ball_color_fbs",
     ],
 )
 
@@ -129,6 +130,19 @@
     ],
 )
 
+cc_binary(
+    name = "superstructure_replay",
+    srcs = ["superstructure_replay.cc"],
+    deps = [
+        ":superstructure_lib",
+        "//aos:configuration",
+        "//aos:init",
+        "//aos/events:simulated_event_loop",
+        "//aos/events/logging:log_reader",
+        "//aos/network:team_number",
+    ],
+)
+
 cc_library(
     name = "collision_avoidance_lib",
     srcs = ["collision_avoidance.cc"],
@@ -180,6 +194,7 @@
         "//frc971/queues:gyro_fbs",
         "//third_party:phoenix",
         "//third_party:wpilib",
+        "//y2022/localizer:localizer_output_fbs",
     ],
 )
 
diff --git a/y2022/control_loops/superstructure/catapult/catapult.cc b/y2022/control_loops/superstructure/catapult/catapult.cc
index a04d8c9..612a17a 100644
--- a/y2022/control_loops/superstructure/catapult/catapult.cc
+++ b/y2022/control_loops/superstructure/catapult/catapult.cc
@@ -390,6 +390,9 @@
         } else {
           // TODO(austin): Voltage error?
           CHECK_NOTNULL(catapult_voltage);
+          if (current_horizon_ == 1) {
+            battery_voltage = 12.0;
+          }
           *catapult_voltage = std::max(
               0.0, std::min(12.0, (*solution - 0.0 * next_X(2, 0)) * 12.0 /
                                       std::max(battery_voltage, 8.0)));
diff --git a/y2022/control_loops/superstructure/collision_avoidance.h b/y2022/control_loops/superstructure/collision_avoidance.h
index 04b8e8a..1d6f89c 100644
--- a/y2022/control_loops/superstructure/collision_avoidance.h
+++ b/y2022/control_loops/superstructure/collision_avoidance.h
@@ -61,7 +61,7 @@
   static constexpr double kMaxCollisionZoneBackTurret = kCollisionZoneTurret;
 
   // Maximum position of the intake to avoid collisions
-  static constexpr double kCollisionZoneIntake = 1.33;
+  static constexpr double kCollisionZoneIntake = 1.30;
 
   // Tolerances for the subsystems
   static constexpr double kEpsTurret = 0.05;
diff --git a/y2022/control_loops/superstructure/led_indicator.cc b/y2022/control_loops/superstructure/led_indicator.cc
index 94d0506..b636250 100644
--- a/y2022/control_loops/superstructure/led_indicator.cc
+++ b/y2022/control_loops/superstructure/led_indicator.cc
@@ -17,6 +17,9 @@
       client_statistics_fetcher_(
           event_loop_->MakeFetcher<aos::message_bridge::ClientStatistics>(
               "/roborio/aos")),
+      localizer_output_fetcher_(
+          event_loop_->MakeFetcher<frc971::controls::LocalizerOutput>(
+              "/localizer")),
       gyro_reading_fetcher_(
           event_loop_->MakeFetcher<frc971::sensors::GyroReading>(
               "/drivetrain")) {
@@ -58,12 +61,6 @@
   }
   return false;
 }
-
-bool DrivingFast(
-    const frc971::control_loops::drivetrain::Output &drivetrain_out) {
-  return (drivetrain_out.left_voltage() >= 11.5 ||
-          drivetrain_out.right_voltage() >= 11.5);
-}
 }  // namespace
 
 void LedIndicator::DecideColor() {
@@ -72,6 +69,15 @@
   drivetrain_output_fetcher_.Fetch();
   client_statistics_fetcher_.Fetch();
   gyro_reading_fetcher_.Fetch();
+  localizer_output_fetcher_.Fetch();
+
+  if (localizer_output_fetcher_.get()) {
+    if (localizer_output_fetcher_->image_accepted_count() !=
+        last_accepted_count_) {
+      last_accepted_count_ = localizer_output_fetcher_->image_accepted_count();
+      last_accepted_time_ = event_loop_->monotonic_now();
+    }
+  }
 
   // Estopped
   if (superstructure_status_fetcher_.get() &&
@@ -90,7 +96,8 @@
   // If the imu gyro readings are not being sent/updated recently
   if (!gyro_reading_fetcher_.get() ||
       gyro_reading_fetcher_.context().monotonic_event_time <
-          event_loop_->monotonic_now() - frc971::controls::kLoopFrequency * 10) {
+          event_loop_->monotonic_now() -
+              frc971::controls::kLoopFrequency * 10) {
     if (imu_flash_) {
       DisplayLed(255, 0, 0);
     } else {
@@ -122,13 +129,6 @@
     return;
   }
 
-  // Driving fast
-  if (drivetrain_output_fetcher_.get() &&
-      DrivingFast(*drivetrain_output_fetcher_)) {
-    DisplayLed(138, 43, 226);
-    return;
-  }
-
   // Statemachine
   if (superstructure_status_fetcher_.get()) {
     switch (superstructure_status_fetcher_->state()) {
@@ -147,18 +147,16 @@
         } else if (superstructure_status_fetcher_->front_intake_has_ball() ||
                    superstructure_status_fetcher_->back_intake_has_ball()) {
           DisplayLed(165, 42, 42);
-        } else {
-          DisplayLed(0, 255, 0);
         }
         break;
       case (SuperstructureState::SHOOTING):
-        if (!superstructure_status_fetcher_->flippers_open()) {
-          DisplayLed(255, 105, 180);
-        } else {
-          DisplayLed(0, 255, 255);
-        }
         break;
     }
+
+    if (event_loop_->monotonic_now() <
+        last_accepted_time_ + std::chrono::seconds(2)) {
+      DisplayLed(255, 0, 255);
+    }
     return;
   }
 }
diff --git a/y2022/control_loops/superstructure/led_indicator.h b/y2022/control_loops/superstructure/led_indicator.h
index 0f44788..c058254 100644
--- a/y2022/control_loops/superstructure/led_indicator.h
+++ b/y2022/control_loops/superstructure/led_indicator.h
@@ -12,6 +12,7 @@
 #include "frc971/queues/gyro_generated.h"
 #include "y2022/control_loops/superstructure/superstructure_output_generated.h"
 #include "y2022/control_loops/superstructure/superstructure_status_generated.h"
+#include "y2022/localizer/localizer_output_generated.h"
 
 namespace y2022::control_loops::superstructure {
 
@@ -58,8 +59,12 @@
       server_statistics_fetcher_;
   aos::Fetcher<aos::message_bridge::ClientStatistics>
       client_statistics_fetcher_;
+  aos::Fetcher<frc971::controls::LocalizerOutput> localizer_output_fetcher_;
   aos::Fetcher<frc971::sensors::GyroReading> gyro_reading_fetcher_;
 
+  size_t last_accepted_count_ = 0;
+  aos::monotonic_clock::time_point last_accepted_time_ =
+      aos::monotonic_clock::min_time;
   size_t imu_counter_ = 0;
   bool imu_flash_ = false;
   size_t disconnected_counter_ = 0;
diff --git a/y2022/control_loops/superstructure/superstructure.cc b/y2022/control_loops/superstructure/superstructure.cc
index 15f9c2a..abf3aef 100644
--- a/y2022/control_loops/superstructure/superstructure.cc
+++ b/y2022/control_loops/superstructure/superstructure.cc
@@ -32,6 +32,10 @@
               "/drivetrain")),
       can_position_fetcher_(
           event_loop->MakeFetcher<CANPosition>("/superstructure")),
+      joystick_state_fetcher_(
+          event_loop->MakeFetcher<aos::JoystickState>("/aos")),
+      ball_color_fetcher_(
+          event_loop->MakeFetcher<y2022::vision::BallColor>("/superstructure")),
       aimer_(values) {
   event_loop->SetRuntimeRealtimePriority(30);
 }
@@ -55,10 +59,43 @@
       frc971::control_loops::StaticZeroingSingleDOFProfiledSubsystemGoal, 512>
       turret_loading_goal_buffer;
   aos::FlatbufferFixedAllocatorArray<CatapultGoal, 512> catapult_goal_buffer;
+  aos::FlatbufferFixedAllocatorArray<CatapultGoal, 512>
+      catapult_discarding_goal_buffer;
 
   const aos::monotonic_clock::time_point timestamp =
       event_loop()->context().monotonic_event_time;
 
+  if (joystick_state_fetcher_.Fetch() &&
+      joystick_state_fetcher_->has_alliance()) {
+    alliance_ = joystick_state_fetcher_->alliance();
+  }
+
+  if (ball_color_fetcher_.Fetch() && ball_color_fetcher_->has_ball_color()) {
+    ball_color_ = ball_color_fetcher_->ball_color();
+  }
+
+  if (alliance_ != aos::Alliance::kInvalid &&
+      ball_color_ != aos::Alliance::kInvalid && alliance_ != ball_color_) {
+    switch (state_) {
+      case SuperstructureState::IDLE:
+        break;
+      case SuperstructureState::TRANSFERRING:
+        break;
+      case SuperstructureState::LOADING:
+        break;
+      case SuperstructureState::LOADED:
+        discarding_ball_ = true;
+        break;
+      case SuperstructureState::SHOOTING:
+        if (!fire_) {
+          // we can still tell it not to shoot into the hub
+          // and change the turret and catapult goals
+          discarding_ball_ = true;
+        }
+        break;
+    }
+  }
+
   drivetrain_status_fetcher_.Fetch();
   const float velocity = robot_velocity();
 
@@ -92,8 +129,9 @@
 
     climber_servo = unsafe_goal->climber_servo();
 
-    turret_goal =
-        unsafe_goal->auto_aim() ? auto_aim_goal : unsafe_goal->turret();
+    turret_goal = unsafe_goal->auto_aim() && !discarding_ball_
+                      ? auto_aim_goal
+                      : unsafe_goal->turret();
 
     catapult_goal = unsafe_goal->catapult();
 
@@ -107,7 +145,7 @@
       std::optional<flatbuffers::Offset<
           frc971::control_loops::StaticZeroingSingleDOFProfiledSubsystemGoal>>
           return_position_offset;
-      if (unsafe_goal != nullptr && unsafe_goal->has_catapult() &&
+      if (unsafe_goal->has_catapult() &&
           unsafe_goal->catapult()->has_return_position()) {
         return_position_offset = {aos::CopyFlatBuffer(
             unsafe_goal->catapult()->return_position(), catapult_goal_fbb)};
@@ -122,6 +160,27 @@
       catapult_goal = &catapult_goal_buffer.message();
     }
 
+    if (discarding_ball_) {
+      flatbuffers::FlatBufferBuilder *catapult_goal_fbb =
+          catapult_discarding_goal_buffer.fbb();
+      std::optional<flatbuffers::Offset<
+          frc971::control_loops::StaticZeroingSingleDOFProfiledSubsystemGoal>>
+          return_position_offset;
+      if (unsafe_goal->has_catapult() &&
+          unsafe_goal->catapult()->has_return_position()) {
+        return_position_offset = {aos::CopyFlatBuffer(
+            unsafe_goal->catapult()->return_position(), catapult_goal_fbb)};
+      }
+      CatapultGoal::Builder builder(*catapult_goal_fbb);
+      builder.add_shot_position(kDiscardingPosition);
+      builder.add_shot_velocity(kDiscardingVelocity);
+      if (return_position_offset.has_value()) {
+        builder.add_return_position(return_position_offset.value());
+      }
+      catapult_discarding_goal_buffer.Finish(builder.Finish());
+      catapult_goal = &catapult_discarding_goal_buffer.message();
+    }
+
     if (unsafe_goal->has_turret_intake()) {
       have_active_intake_request = true;
     }
@@ -180,7 +239,7 @@
     back_intake_beambreak_timer_ = timestamp;
   }
 
-  // Check if we're either spitting of have lost the ball.
+  // Check if we're either spitting or have lost the ball.
   if ((transfer_roller_speed < 0.0 && front_intake_has_ball_) ||
       timestamp >
           front_intake_beambreak_timer_ + constants::Values::kBallLostTime()) {
@@ -222,17 +281,24 @@
       (turret_intake_state_ == RequestedIntake::kFront
            ? constants::Values::kTurretFrontIntakePos()
            : constants::Values::kTurretBackIntakePos());
-  // Turn to the loading position as close to the current position as
-  // possible.
-  turret_loading_position =
-      turret_.estimated_position() +
-      aos::math::NormalizeAngle(turret_loading_position -
-                                turret_.estimated_position());
-  // if out of range, reset back to within +/- pi of zero.
-  if (turret_loading_position > constants::Values::kTurretRange().upper ||
-      turret_loading_position < constants::Values::kTurretRange().lower) {
+  if (transitioning_second_ball_) {
+    // Turn to the loading position as close to the current position as
+    // possible since we just aimed.
     turret_loading_position =
-        frc971::zeroing::Wrap(constants::Values::kTurretRange().middle_soft(),
+        turret_.estimated_position() +
+        aos::math::NormalizeAngle(turret_loading_position -
+                                  turret_.estimated_position());
+  }
+
+  if (!transitioning_second_ball_ ||
+      (turret_loading_position > values_->turret_range.upper ||
+       turret_loading_position < values_->turret_range.lower)) {
+    // Turn to the loading position as close to the middle of the range as
+    // possible. Do the unwraping before we have a ball so we don't have to
+    // unwrap to shoot if we aren't transitioning a second ball. If we are doing
+    // the second ball, we need to reset back to the middle of the range
+    turret_loading_position =
+        frc971::zeroing::Wrap(values_->turret_range.middle_soft(),
                               turret_loading_position, 2.0 * M_PI);
   }
 
@@ -240,6 +306,12 @@
       frc971::control_loops::CreateStaticZeroingSingleDOFProfiledSubsystemGoal(
           *turret_loading_goal_buffer.fbb(), turret_loading_position));
 
+  const bool catapult_near_return_position =
+      (unsafe_goal != nullptr && unsafe_goal->has_catapult() &&
+       unsafe_goal->catapult()->has_return_position() &&
+       std::abs(unsafe_goal->catapult()->return_position()->unsafe_goal() -
+                catapult_.estimated_position()) < kCatapultGoalThreshold);
+
   const bool turret_near_goal =
       turret_goal != nullptr &&
       std::abs(turret_goal->unsafe_goal() - turret_.position()) <
@@ -288,15 +360,17 @@
           (turret_intake_state_ == RequestedIntake::kBack &&
            !back_intake_has_ball_)) {
         state_ = SuperstructureState::IDLE;
+        transitioning_second_ball_ = false;
         break;
       }
 
       turret_goal = &turret_loading_goal_buffer.message();
+      aimer_.UpdateTurretGoal(turret_loading_position);
 
       const bool turret_near_goal =
           std::abs(turret_.estimated_position() - turret_loading_position) <
-          kTurretGoalThreshold;
-      if (!turret_near_goal) {
+          kTurretGoalLoadingThreshold;
+      if (!turret_near_goal || !catapult_near_return_position) {
         break;  // Wait for turret to reach the chosen intake
       }
 
@@ -333,6 +407,8 @@
       } else if (timestamp >
                  loading_timer_ + constants::Values::kExtraLoadingTime()) {
         state_ = SuperstructureState::LOADED;
+        // reset color and wait for a new one once we know the ball is in place
+        ball_color_ = aos::Alliance::kInvalid;
         reseating_in_catapult_ = false;
       }
       break;
@@ -346,16 +422,18 @@
           }
           turret_goal = &turret_loading_goal_buffer.message();
         }
+
         if (unsafe_goal->cancel_shot()) {
           // Cancel the shot process
           state_ = SuperstructureState::IDLE;
-        } else if (unsafe_goal->fire()) {
+        } else if (unsafe_goal->fire() || discarding_ball_) {
           // Start if we were asked to and the turret is at goal
           state_ = SuperstructureState::SHOOTING;
           prev_shot_count_ = catapult_.shot_count();
 
           // Reset opening timeout
           flipper_opening_start_time_ = timestamp;
+          loading_timer_ = timestamp;
         }
       }
       break;
@@ -424,18 +502,15 @@
         fire_ = true;
       }
 
-      const bool near_return_position =
-          (unsafe_goal != nullptr && unsafe_goal->has_catapult() &&
-           unsafe_goal->catapult()->has_return_position() &&
-           std::abs(unsafe_goal->catapult()->return_position()->unsafe_goal() -
-                    catapult_.estimated_position()) < kCatapultGoalThreshold);
-
       // Once the shot is complete and the catapult is back to its return
       // position, go back to IDLE
-      if (catapult_.shot_count() > prev_shot_count_ && near_return_position) {
+      if (catapult_.shot_count() > prev_shot_count_) {
         prev_shot_count_ = catapult_.shot_count();
         fire_ = false;
+        discarding_ball_ = false;
         state_ = SuperstructureState::IDLE;
+        transitioning_second_ball_ =
+            (front_intake_has_ball_ || back_intake_has_ball_);
       }
 
       break;
@@ -446,7 +521,8 @@
       {.intake_front_position = intake_front_.estimated_position(),
        .intake_back_position = intake_back_.estimated_position(),
        .turret_position = turret_.estimated_position(),
-       .shooting = state_ == SuperstructureState::SHOOTING},
+       .shooting = (state_ == SuperstructureState::SHOOTING) ||
+                   !catapult_near_return_position},
       turret_goal);
 
   turret_.set_min_position(collision_avoidance_.min_turret_goal());
@@ -502,7 +578,7 @@
     output_struct.transfer_roller_voltage = transfer_roller_speed;
     output_struct.flipper_arms_voltage = flipper_arms_voltage;
     if (climber_servo) {
-      output_struct.climber_servo_left = 0.0;
+      output_struct.climber_servo_left = 0.5;
       output_struct.climber_servo_right = 1.0;
     } else {
       output_struct.climber_servo_left = 1.0;
@@ -542,6 +618,8 @@
   status_builder.add_reseating_in_catapult(reseating_in_catapult_);
   status_builder.add_fire(fire_);
   status_builder.add_moving_too_fast(moving_too_fast);
+  status_builder.add_discarding_ball(discarding_ball_);
+  status_builder.add_collided(collided);
   status_builder.add_ready_to_fire(state_ == SuperstructureState::LOADED &&
                                    turret_near_goal && !collided);
   status_builder.add_state(state_);
@@ -559,6 +637,7 @@
   }
   status_builder.add_front_intake_has_ball(front_intake_has_ball_);
   status_builder.add_back_intake_has_ball(back_intake_has_ball_);
+  status_builder.add_transitioning_second_ball(transitioning_second_ball_);
 
   status_builder.add_aimer(aimer_offset);
 
diff --git a/y2022/control_loops/superstructure/superstructure.h b/y2022/control_loops/superstructure/superstructure.h
index 14fa8ab..5e3415c 100644
--- a/y2022/control_loops/superstructure/superstructure.h
+++ b/y2022/control_loops/superstructure/superstructure.h
@@ -13,6 +13,7 @@
 #include "y2022/control_loops/superstructure/superstructure_position_generated.h"
 #include "y2022/control_loops/superstructure/superstructure_status_generated.h"
 #include "y2022/control_loops/superstructure/turret/aiming.h"
+#include "y2022/vision/ball_color_generated.h"
 
 namespace y2022 {
 namespace control_loops {
@@ -32,9 +33,12 @@
           ::frc971::control_loops::PotAndAbsoluteEncoderProfiledJointStatus>;
 
   static constexpr double kTurretGoalThreshold = 0.05;
+  static constexpr double kTurretGoalLoadingThreshold = 0.70;
   static constexpr double kCatapultGoalThreshold = 0.05;
   // potentiometer will be more noisy
   static constexpr double kFlipperGoalThreshold = 0.05;
+  static constexpr double kDiscardingPosition = 0.35;
+  static constexpr double kDiscardingVelocity = 6.0;
 
   explicit Superstructure(::aos::EventLoop *event_loop,
                           std::shared_ptr<const constants::Values> values,
@@ -72,6 +76,8 @@
   aos::Fetcher<frc971::control_loops::drivetrain::Status>
       drivetrain_status_fetcher_;
   aos::Fetcher<CANPosition> can_position_fetcher_;
+  aos::Fetcher<aos::JoystickState> joystick_state_fetcher_;
+  aos::Fetcher<y2022::vision::BallColor> ball_color_fetcher_;
 
   int prev_shot_count_ = 0;
 
@@ -80,6 +86,9 @@
   bool flippers_open_ = false;
   bool reseating_in_catapult_ = false;
   bool fire_ = false;
+  bool discarding_ball_ = false;
+  aos::Alliance alliance_ = aos::Alliance::kInvalid;
+  aos::Alliance ball_color_ = aos::Alliance::kInvalid;
 
   aos::monotonic_clock::time_point front_intake_beambreak_timer_ =
       aos::monotonic_clock::min_time;
@@ -94,6 +103,7 @@
   SuperstructureState state_ = SuperstructureState::IDLE;
   bool front_intake_has_ball_ = false;
   bool back_intake_has_ball_ = false;
+  bool transitioning_second_ball_ = false;
   std::optional<double> last_shot_angle_ = std::nullopt;
   RequestedIntake turret_intake_state_ = RequestedIntake::kFront;
 
diff --git a/y2022/control_loops/superstructure/superstructure_lib_test.cc b/y2022/control_loops/superstructure/superstructure_lib_test.cc
index 7123a47..e582d9e 100644
--- a/y2022/control_loops/superstructure/superstructure_lib_test.cc
+++ b/y2022/control_loops/superstructure/superstructure_lib_test.cc
@@ -158,7 +158,7 @@
                 PositionSensorSimulator(
                     values->turret.subsystem_params.zeroing_constants
                         .one_revolution_distance),
-                values->turret, constants::Values::kTurretRange(),
+                values->turret, values->turret_range,
                 values->turret.subsystem_params.zeroing_constants
                     .measured_absolute_position,
                 dt_),
@@ -178,7 +178,7 @@
     intake_front_.InitializePosition(
         constants::Values::kIntakeRange().middle());
     intake_back_.InitializePosition(constants::Values::kIntakeRange().middle());
-    turret_.InitializePosition(constants::Values::kTurretRange().middle());
+    turret_.InitializePosition(values->turret_range.middle());
     catapult_.InitializePosition(constants::Values::kCatapultRange().middle());
     climber_.InitializePosition(constants::Values::kClimberRange().middle());
 
@@ -328,9 +328,11 @@
         values_(std::make_shared<constants::Values>(constants::MakeValues(
             frc971::control_loops::testing::kTeamNumber))),
         roborio_(aos::configuration::GetNode(configuration(), "roborio")),
+        logger_pi_(aos::configuration::GetNode(configuration(), "logger")),
         superstructure_event_loop(MakeEventLoop("Superstructure", roborio_)),
         superstructure_(superstructure_event_loop.get(), values_),
         test_event_loop_(MakeEventLoop("test", roborio_)),
+        ball_color_event_loop_(MakeEventLoop("ball color test", logger_pi_)),
         superstructure_goal_fetcher_(
             test_event_loop_->MakeFetcher<Goal>("/superstructure")),
         superstructure_goal_sender_(
@@ -345,6 +347,9 @@
             test_event_loop_->MakeSender<Position>("/superstructure")),
         drivetrain_status_sender_(
             test_event_loop_->MakeSender<DrivetrainStatus>("/drivetrain")),
+        ball_color_sender_(
+            ball_color_event_loop_->MakeSender<y2022::vision::BallColor>(
+                "/superstructure")),
         superstructure_plant_event_loop_(MakeEventLoop("plant", roborio_)),
         superstructure_plant_(superstructure_plant_event_loop_.get(), values_,
                               dt()) {
@@ -476,10 +481,12 @@
   std::shared_ptr<const constants::Values> values_;
 
   const aos::Node *const roborio_;
+  const aos::Node *const logger_pi_;
 
   ::std::unique_ptr<::aos::EventLoop> superstructure_event_loop;
   ::y2022::control_loops::superstructure::Superstructure superstructure_;
   ::std::unique_ptr<::aos::EventLoop> test_event_loop_;
+  ::std::unique_ptr<aos::EventLoop> ball_color_event_loop_;
   ::aos::PhasedLoopHandler *phased_loop_handle_ = nullptr;
 
   ::aos::Fetcher<Goal> superstructure_goal_fetcher_;
@@ -489,6 +496,7 @@
   ::aos::Fetcher<Position> superstructure_position_fetcher_;
   ::aos::Sender<Position> superstructure_position_sender_;
   ::aos::Sender<DrivetrainStatus> drivetrain_status_sender_;
+  ::aos::Sender<y2022::vision::BallColor> ball_color_sender_;
 
   ::std::unique_ptr<::aos::EventLoop> superstructure_plant_event_loop_;
   SuperstructureSimulation superstructure_plant_;
@@ -558,7 +566,7 @@
   superstructure_plant_.intake_back()->InitializePosition(
       constants::Values::kIntakeRange().middle());
   superstructure_plant_.turret()->InitializePosition(
-      constants::Values::kTurretRange().middle());
+      values_->turret_range.middle());
   superstructure_plant_.climber()->InitializePosition(
       constants::Values::kClimberRange().middle());
   WaitUntilZeroed();
@@ -576,7 +584,7 @@
 
     flatbuffers::Offset<StaticZeroingSingleDOFProfiledSubsystemGoal>
         turret_offset = CreateStaticZeroingSingleDOFProfiledSubsystemGoal(
-            *builder.fbb(), constants::Values::kTurretRange().lower,
+            *builder.fbb(), values_->turret_range.lower,
             CreateProfileParameters(*builder.fbb(), 1.0, 0.2));
 
     flatbuffers::Offset<StaticZeroingSingleDOFProfiledSubsystemGoal>
@@ -663,7 +671,7 @@
 
     flatbuffers::Offset<StaticZeroingSingleDOFProfiledSubsystemGoal>
         turret_offset = CreateStaticZeroingSingleDOFProfiledSubsystemGoal(
-            *builder.fbb(), constants::Values::kTurretRange().lower,
+            *builder.fbb(), values_->turret_range.lower,
             CreateProfileParameters(*builder.fbb(), 20.0, 0.1));
 
     flatbuffers::Offset<StaticZeroingSingleDOFProfiledSubsystemGoal>
@@ -756,17 +764,26 @@
   SendRobotVelocity(1.0);
 
   constexpr double kTurretGoal = 2.0;
+  constexpr double kCatapultReturnPosition = -0.87;
   {
     auto builder = superstructure_goal_sender_.MakeBuilder();
     flatbuffers::Offset<StaticZeroingSingleDOFProfiledSubsystemGoal>
         turret_offset = CreateStaticZeroingSingleDOFProfiledSubsystemGoal(
             *builder.fbb(), kTurretGoal);
+    const auto catapult_return_offset =
+        CreateStaticZeroingSingleDOFProfiledSubsystemGoal(
+            *builder.fbb(), kCatapultReturnPosition);
+    auto catapult_builder = builder.MakeBuilder<CatapultGoal>();
+    catapult_builder.add_return_position(catapult_return_offset);
+    const auto catapult_offset = catapult_builder.Finish();
+
     Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
     goal_builder.add_roller_speed_front(12.0);
     goal_builder.add_roller_speed_back(12.0);
     goal_builder.add_roller_speed_compensation(0.0);
     goal_builder.add_turret(turret_offset);
     goal_builder.add_turret_intake(RequestedIntake::kFront);
+    goal_builder.add_catapult(catapult_offset);
     builder.CheckOk(builder.Send(goal_builder.Finish()));
   }
   RunFor(std::chrono::seconds(2));
@@ -916,8 +933,8 @@
             *builder.fbb(), kTurretGoal);
 
     const auto catapult_return_offset =
-        CreateStaticZeroingSingleDOFProfiledSubsystemGoal(*builder.fbb(),
-                                                          -0.87);
+        CreateStaticZeroingSingleDOFProfiledSubsystemGoal(
+            *builder.fbb(), kCatapultReturnPosition);
     auto catapult_builder = builder.MakeBuilder<CatapultGoal>();
     catapult_builder.add_shot_position(0.3);
     catapult_builder.add_shot_velocity(15.0);
@@ -980,6 +997,7 @@
             SuperstructureState::TRANSFERRING);
   EXPECT_EQ(superstructure_status_fetcher_->intake_state(),
             IntakeState::INTAKE_BACK_BALL);
+  EXPECT_TRUE(superstructure_status_fetcher_->transitioning_second_ball());
   EXPECT_NEAR(superstructure_status_fetcher_->turret()->position(),
               -constants::Values::kTurretBackIntakePos(), 0.001);
 
@@ -989,11 +1007,12 @@
   ASSERT_TRUE(superstructure_status_fetcher_.Fetch());
   EXPECT_EQ(superstructure_status_fetcher_->shot_count(), 1);
   EXPECT_EQ(superstructure_status_fetcher_->state(), SuperstructureState::IDLE);
+  EXPECT_FALSE(superstructure_status_fetcher_->transitioning_second_ball());
   EXPECT_EQ(superstructure_status_fetcher_->intake_state(),
             IntakeState::NO_BALL);
 }
 
-TEST_F(SuperstructureTest, TestTurretWrapsWhenLoading) {
+TEST_F(SuperstructureTest, TestTurretUnWrapsWhenLoading) {
   SetEnabled(true);
   WaitUntilZeroed();
 
@@ -1015,21 +1034,22 @@
   EXPECT_NEAR(superstructure_status_fetcher_->turret()->position(), kTurretGoal,
               0.001);
 
-  superstructure_plant_.set_intake_beambreak_front(true);
+  superstructure_plant_.set_intake_beambreak_back(true);
   RunFor(dt() * 2);
 
   ASSERT_TRUE(superstructure_status_fetcher_.Fetch());
   EXPECT_EQ(superstructure_status_fetcher_->state(),
             SuperstructureState::TRANSFERRING);
   EXPECT_EQ(superstructure_status_fetcher_->intake_state(),
-            IntakeState::INTAKE_FRONT_BALL);
+            IntakeState::INTAKE_BACK_BALL);
 
   RunFor(std::chrono::seconds(3));
 
   ASSERT_TRUE(superstructure_status_fetcher_.Fetch());
   EXPECT_NEAR(superstructure_status_fetcher_->turret()->position(),
-              -constants::Values::kTurretFrontIntakePos() - 2.0 * M_PI, 0.001);
-  // it chooses -pi because -pi is closer to -4 than positive pi
+              constants::Values::kTurretBackIntakePos(), 0.001);
+  // It goes to -pi instead of +pi because -pi is closest to the center of the
+  // range at -1.675.
 }
 
 // Make sure that the front and back intakes are never switched
@@ -1285,6 +1305,80 @@
             shot_params.shot_angle);
 }
 
+// Tests that balls get discarded when they are the wrong color.
+TEST_F(SuperstructureTest, BallDiscarding) {
+  set_alliance(aos::Alliance::kInvalid);
+  SetEnabled(true);
+  WaitUntilZeroed();
+
+  // Set ourselves up 5m from the target--the turret goal should be 90 deg (we
+  // need to shoot out the right of the robot, and we shoot out of the back of
+  // the turret).
+  SendDrivetrainStatus(0.0, {0.0, 5.0}, 0.0);
+
+  RunFor(chrono::milliseconds(500));
+  set_alliance(aos::Alliance::kBlue);
+
+  {
+    auto builder = superstructure_goal_sender_.MakeBuilder();
+
+    Goal::Builder goal_builder = builder.MakeBuilder<Goal>();
+
+    goal_builder.add_auto_aim(true);
+    goal_builder.add_preloaded(true);
+    goal_builder.add_turret_intake(RequestedIntake::kFront);
+
+    ASSERT_EQ(builder.Send(goal_builder.Finish()), aos::RawSender::Error::kOk);
+  }
+
+  // Give it time to stabilize.
+  RunFor(chrono::seconds(2));
+
+  superstructure_status_fetcher_.Fetch();
+  EXPECT_NEAR(M_PI_2, superstructure_status_fetcher_->turret()->position(),
+              5e-4);
+
+  {
+    auto builder = ball_color_sender_.MakeBuilder();
+
+    y2022::vision::BallColor::Builder ball_color_builder =
+        builder.MakeBuilder<y2022::vision::BallColor>();
+
+    ball_color_builder.add_ball_color(aos::Alliance::kBlue);
+
+    ASSERT_EQ(builder.Send(ball_color_builder.Finish()),
+              aos::RawSender::Error::kOk);
+  }
+
+  RunFor(chrono::milliseconds(100));
+  superstructure_status_fetcher_.Fetch();
+  EXPECT_EQ(superstructure_status_fetcher_->state(),
+            SuperstructureState::LOADED);
+
+  {
+    auto builder = ball_color_sender_.MakeBuilder();
+
+    y2022::vision::BallColor::Builder ball_color_builder =
+        builder.MakeBuilder<y2022::vision::BallColor>();
+
+    ball_color_builder.add_ball_color(aos::Alliance::kRed);
+
+    ASSERT_EQ(builder.Send(ball_color_builder.Finish()),
+              aos::RawSender::Error::kOk);
+  }
+
+  RunFor(dt());
+
+  superstructure_status_fetcher_.Fetch();
+  EXPECT_EQ(superstructure_status_fetcher_->state(),
+            SuperstructureState::SHOOTING);
+
+  RunFor(chrono::milliseconds(2000));
+  superstructure_status_fetcher_.Fetch();
+  EXPECT_NEAR(constants::Values::kTurretFrontIntakePos(),
+              superstructure_status_fetcher_->turret()->position(), 5e-4);
+}
+
 }  // namespace testing
 }  // namespace superstructure
 }  // namespace control_loops
diff --git a/y2022/control_loops/superstructure/superstructure_plotter.ts b/y2022/control_loops/superstructure/superstructure_plotter.ts
index f8c3a7c..5a1537e 100644
--- a/y2022/control_loops/superstructure/superstructure_plotter.ts
+++ b/y2022/control_loops/superstructure/superstructure_plotter.ts
@@ -32,8 +32,8 @@
       .setColor(RED)
       .setPointSize(4.0);
   positionPlot.addMessageLine(status, ['state'])
-      .setColor(CYAN)
-      .setPointSize(1.0);
+      .setColor(PINK)
+      .setPointSize(4.0);
   positionPlot.addMessageLine(status, ['flippers_open'])
       .setColor(WHITE)
       .setPointSize(1.0);
@@ -41,9 +41,24 @@
       .setColor(BLUE)
       .setPointSize(1.0);
   positionPlot.addMessageLine(status, ['fire'])
-      .setColor(CYAN)
+      .setColor(BROWN)
+      .setPointSize(1.0);
+  positionPlot.addMessageLine(status, ['ready_to_fire'])
+      .setColor(GREEN)
+      .setPointSize(1.0);
+  positionPlot.addMessageLine(status, ['collided'])
+      .setColor(PINK)
       .setPointSize(1.0);
 
+  const shotCountPlot =
+      aosPlotter.addPlot(element, [DEFAULT_WIDTH, DEFAULT_HEIGHT / 2]);
+  shotCountPlot.plot.getAxisLabels().setTitle('Shot Count');
+  shotCountPlot.plot.getAxisLabels().setXLabel(TIME);
+  shotCountPlot.plot.getAxisLabels().setYLabel('balls');
+  shotCountPlot.plot.setDefaultYRange([-1.0, 2.0]);
+  shotCountPlot.addMessageLine(status, ['shot_count'])
+      .setColor(RED)
+      .setPointSize(1.0);
 
   const intakePlot =
       aosPlotter.addPlot(element, [DEFAULT_WIDTH, DEFAULT_HEIGHT / 2]);
@@ -60,6 +75,9 @@
   intakePlot.addMessageLine(position, ['intake_beambreak_back'])
       .setColor(PINK)
       .setPointSize(1.0);
+  intakePlot.addMessageLine(output, ['transfer_roller_voltage'])
+      .setColor(BROWN)
+      .setPointSize(3.0);
 
 
   const otherPlot =
@@ -72,6 +90,9 @@
   otherPlot.addMessageLine(status, ['catapult', 'position'])
       .setColor(PINK)
       .setPointSize(4.0);
+  otherPlot.addMessageLine(status, ['turret', 'position'])
+      .setColor(WHITE)
+      .setPointSize(4.0);
   otherPlot.addMessageLine(position, ['flipper_arm_left', 'encoder'])
       .setColor(BLUE)
       .setPointSize(4.0);
diff --git a/y2022/control_loops/superstructure/superstructure_replay.cc b/y2022/control_loops/superstructure/superstructure_replay.cc
new file mode 100644
index 0000000..b05cdb9
--- /dev/null
+++ b/y2022/control_loops/superstructure/superstructure_replay.cc
@@ -0,0 +1,74 @@
+// This binary allows us to replay the superstructure code over existing logfile.
+// When you run this code, it generates a new logfile with the data all
+// replayed, so that it can then be run through the plotting tool or analyzed
+// in some other way. The original superstructure status data will be on the
+// /original/superstructure channel.
+#include "aos/events/logging/log_reader.h"
+#include "aos/events/logging/log_writer.h"
+#include "aos/events/simulated_event_loop.h"
+#include "aos/init.h"
+#include "aos/json_to_flatbuffer.h"
+#include "aos/logging/log_message_generated.h"
+#include "aos/network/team_number.h"
+#include "gflags/gflags.h"
+#include "y2022/constants.h"
+#include "y2022/control_loops/superstructure/superstructure.h"
+
+DEFINE_int32(team, 971, "Team number to use for logfile replay.");
+DEFINE_string(output_folder, "/tmp/superstructure_replay/",
+              "Logs all channels to the provided logfile.");
+
+int main(int argc, char **argv) {
+  aos::InitGoogle(&argc, &argv);
+
+  aos::network::OverrideTeamNumber(FLAGS_team);
+
+  // open logfiles
+  aos::logger::LogReader reader(
+      aos::logger::SortParts(aos::logger::FindLogs(argc, argv)));
+  // TODO(james): Actually enforce not sending on the same buses as the logfile
+  // spews out.
+  reader.RemapLoggedChannel("/superstructure",
+                            "y2022.control_loops.superstructure.Status");
+  reader.RemapLoggedChannel("/superstructure",
+                            "y2022.control_loops.superstructure.Output");
+
+  aos::SimulatedEventLoopFactory factory(reader.configuration());
+  reader.Register(&factory);
+
+  aos::NodeEventLoopFactory *roborio =
+      factory.GetNodeEventLoopFactory("roborio");
+
+  unlink(FLAGS_output_folder.c_str());
+  std::unique_ptr<aos::EventLoop> logger_event_loop =
+      roborio->MakeEventLoop("logger");
+  auto logger = std::make_unique<aos::logger::Logger>(logger_event_loop.get());
+  logger->StartLoggingOnRun(FLAGS_output_folder);
+
+  roborio->OnStartup([roborio]() {
+    roborio->AlwaysStart<y2022::control_loops::superstructure::Superstructure>(
+        "superstructure", std::make_shared<y2022::constants::Values>(
+                              y2022::constants::MakeValues()));
+  });
+
+  std::unique_ptr<aos::EventLoop> print_loop = roborio->MakeEventLoop("print");
+  print_loop->SkipAosLog();
+  print_loop->MakeWatcher(
+      "/aos", [&print_loop](const aos::logging::LogMessageFbs &msg) {
+        LOG(INFO) << print_loop->context().monotonic_event_time << " "
+                  << aos::FlatbufferToJson(&msg);
+      });
+  print_loop->MakeWatcher(
+      "/superstructure",
+      [&](const y2022::control_loops::superstructure::Status &status) {
+        if (status.estopped()) {
+          LOG(ERROR) << "Estopped";
+        }
+      });
+
+  factory.Run();
+
+  reader.Deregister();
+
+  return 0;
+}
diff --git a/y2022/control_loops/superstructure/superstructure_status.fbs b/y2022/control_loops/superstructure/superstructure_status.fbs
index 9cf9a5f..bfa8f3f 100644
--- a/y2022/control_loops/superstructure/superstructure_status.fbs
+++ b/y2022/control_loops/superstructure/superstructure_status.fbs
@@ -51,14 +51,21 @@
   // Whether the front/rear intakes currently are holding balls.
   front_intake_has_ball:bool (id: 18);
   back_intake_has_ball:bool (id: 19);
+  // Whether we just shot a ball and are transitioning a second one
+  transitioning_second_ball:bool (id: 24);
   // Whether the flippers are open for shooting
   flippers_open:bool (id: 12);
   // Whether the flippers failed to open and we are retrying
   reseating_in_catapult:bool (id: 13);
+  // Whether the turret/catapult is collided with the intake
+  collided:bool(id: 23);
   // Whether the turret is ready for firing
   ready_to_fire:bool (id: 20);
   // Whether the robot is moving too fast to shoot
   moving_too_fast:bool (id: 21);
+  // True if the robot has detected that it is holding
+  // the wrong color ball and is now discarding it.
+  discarding_ball:bool (id: 22);
   // Whether the catapult was told to fire,
   // meaning that the turret and flippers are ready for firing
   // and we were asked to fire. Different from fire flag in goal.
diff --git a/y2022/control_loops/superstructure/turret/aiming.cc b/y2022/control_loops/superstructure/turret/aiming.cc
index 5fe8a2c..6320cb7 100644
--- a/y2022/control_loops/superstructure/turret/aiming.cc
+++ b/y2022/control_loops/superstructure/turret/aiming.cc
@@ -8,8 +8,8 @@
 namespace turret {
 
 using frc971::control_loops::Pose;
-using frc971::control_loops::aiming::ShotConfig;
 using frc971::control_loops::aiming::RobotState;
+using frc971::control_loops::aiming::ShotConfig;
 
 namespace {
 // If the turret is at zero, then it will be at this angle at which the shot
@@ -55,7 +55,7 @@
   // Use the previous shot distance to estimate the speed-over-ground of the
   // ball.
   current_goal_ = frc971::control_loops::aiming::AimerGoal(
-      ShotConfig{goal, shot_mode, constants_->kTurretRange(),
+      ShotConfig{goal, shot_mode, constants_->turret_range,
                  constants_->shot_velocity_interpolation_table
                      .Get(current_goal_.target_distance)
                      .shot_speed_over_ground,
diff --git a/y2022/control_loops/superstructure/turret/aiming.h b/y2022/control_loops/superstructure/turret/aiming.h
index 4eabe3e..a762298 100644
--- a/y2022/control_loops/superstructure/turret/aiming.h
+++ b/y2022/control_loops/superstructure/turret/aiming.h
@@ -24,6 +24,9 @@
 
   void Update(const Status *status, ShotMode shot_mode);
 
+  void UpdateTurretGoal(double turret_goal) {
+    goal_.mutable_message()->mutate_unsafe_goal(turret_goal);
+  }
   const Goal *TurretGoal() const { return &goal_.message(); }
 
   // Returns the distance to the goal, in meters.
diff --git a/y2022/joystick_reader.cc b/y2022/joystick_reader.cc
index 05f3008..26c7f33 100644
--- a/y2022/joystick_reader.cc
+++ b/y2022/joystick_reader.cc
@@ -64,8 +64,9 @@
 
 const ButtonLocation kIntakeFrontOut(4, 10);
 const ButtonLocation kIntakeBackOut(4, 9);
-const ButtonLocation kSpitFront(3, 3);
-const ButtonLocation kSpitBack(3, 1);
+const ButtonLocation kSpitFront(4, 8);
+const ButtonLocation kSpitBack(4, 7);
+const ButtonLocation kSpit(3, 3);
 
 const ButtonLocation kRedLocalizerReset(4, 14);
 const ButtonLocation kBlueLocalizerReset(4, 13);
@@ -232,6 +233,13 @@
     constexpr double kIntakePosition = -0.12;
     constexpr size_t kIntakeCounterIterations = 25;
 
+    if (data.PosEdge(kSpit)) {
+      last_front_intake_has_ball_ =
+          superstructure_status_fetcher_->front_intake_has_ball();
+      last_back_intake_has_ball_ =
+          superstructure_status_fetcher_->back_intake_has_ball();
+    }
+
     // Extend the intakes and spin the rollers.
     // Don't let this happen if there is a ball in the other intake, because
     // that would spit this one out.
@@ -249,10 +257,12 @@
 
       intake_back_counter_ = kIntakeCounterIterations;
       intake_front_counter_ = 0;
-    } else if (data.IsPressed(kSpitFront)) {
+    } else if (data.IsPressed(kSpitFront) ||
+               (data.IsPressed(kSpit) && last_front_intake_has_ball_)) {
       transfer_roller_speed = -kTransferRollerSpeed;
       intake_front_counter_ = 0;
-    } else if (data.IsPressed(kSpitBack)) {
+    } else if (data.IsPressed(kSpitBack) ||
+               (data.IsPressed(kSpit) && last_back_intake_has_ball_)) {
       transfer_roller_speed = kTransferRollerSpeed;
       intake_back_counter_ = 0;
     }
@@ -298,7 +308,7 @@
       if (turret_pos.has_value()) {
         turret_offset = CreateStaticZeroingSingleDOFProfiledSubsystemGoal(
             *builder.fbb(), turret_pos.value(),
-            CreateProfileParameters(*builder.fbb(), 12.0, 20.0));
+            CreateProfileParameters(*builder.fbb(), 10.0, 20.0));
       }
 
       flatbuffers::Offset<StaticZeroingSingleDOFProfiledSubsystemGoal>
@@ -361,6 +371,9 @@
 
   size_t intake_front_counter_ = 0;
   size_t intake_back_counter_ = 0;
+
+  bool last_front_intake_has_ball_ = false;
+  bool last_back_intake_has_ball_ = false;
 };
 
 }  // namespace joysticks
diff --git a/y2022/localizer/BUILD b/y2022/localizer/BUILD
index edc3791..243259a 100644
--- a/y2022/localizer/BUILD
+++ b/y2022/localizer/BUILD
@@ -105,6 +105,7 @@
         "//frc971/control_loops/drivetrain:drivetrain_status_fbs",
         "//frc971/control_loops/drivetrain:improved_down_estimator",
         "//frc971/control_loops/drivetrain:localizer_fbs",
+        "//frc971/input:joystick_state_fbs",
         "//frc971/wpilib:imu_batch_fbs",
         "//frc971/wpilib:imu_fbs",
         "//frc971/zeroing:imu_zeroer",
diff --git a/y2022/localizer/localizer.cc b/y2022/localizer/localizer.cc
index 783d368..4b0588c 100644
--- a/y2022/localizer/localizer.cc
+++ b/y2022/localizer/localizer.cc
@@ -642,7 +642,11 @@
   H_model(1, kY) = 1.0;
   H_accel(0, kX) = 1.0;
   H_accel(1, kY) = 1.0;
-  R.diagonal() << 1e-2, 1e-2;
+  if (aggressive_corrections_) {
+    R.diagonal() << 1e-2, 1e-2;
+  } else {
+    R.diagonal() << 1e-0, 1e-0;
+  }
 
   const Eigen::Matrix<double, kNModelStates, 2> K_model =
       P_model_ * H_model.transpose() *
@@ -886,6 +890,8 @@
           event_loop_
               ->MakeFetcher<y2022::control_loops::superstructure::Status>(
                   "/superstructure")),
+      joystick_state_fetcher_(
+          event_loop_->MakeFetcher<aos::JoystickState>("/roborio/aos")),
       zeroer_(zeroing::ImuZeroer::FaultBehavior::kTemporary),
       left_encoder_(-DrivetrainWrapPeriod() / 2.0, DrivetrainWrapPeriod()),
       right_encoder_(-DrivetrainWrapPeriod() / 2.0, DrivetrainWrapPeriod()) {
@@ -926,39 +932,44 @@
             absl::StrCat("/", kPisToUse[camera_index], "/camera"));
   }
   aos::TimerHandler *estimate_timer = event_loop_->AddTimer([this]() {
-    for (size_t camera_index = 0; camera_index < kPisToUse.size();
-         ++camera_index) {
-      if (model_based_.NumQueuedImageDebugs() ==
-              ModelBasedLocalizer::kDebugBufferSize ||
-          (last_visualization_send_ + kMinVisualizationPeriod <
-           event_loop_->monotonic_now())) {
-        auto builder = visualization_sender_.MakeBuilder();
-        visualization_sender_.CheckOk(
-            builder.Send(model_based_.PopulateVisualization(builder.fbb())));
-      }
-      if (target_estimate_fetchers_[camera_index].Fetch()) {
-        const std::optional<aos::monotonic_clock::duration> monotonic_offset =
-            ClockOffset(kPisToUse[camera_index]);
-        if (!monotonic_offset.has_value()) {
-          continue;
+      joystick_state_fetcher_.Fetch();
+      const bool maybe_in_auto = (joystick_state_fetcher_.get() != nullptr)
+                                     ? joystick_state_fetcher_->autonomous()
+                                     : true;
+      model_based_.set_use_aggressive_image_corrections(!maybe_in_auto);
+      for (size_t camera_index = 0; camera_index < kPisToUse.size();
+           ++camera_index) {
+        if (model_based_.NumQueuedImageDebugs() ==
+                ModelBasedLocalizer::kDebugBufferSize ||
+            (last_visualization_send_ + kMinVisualizationPeriod <
+             event_loop_->monotonic_now())) {
+          auto builder = visualization_sender_.MakeBuilder();
+          visualization_sender_.CheckOk(
+              builder.Send(model_based_.PopulateVisualization(builder.fbb())));
         }
-        // TODO(james): Get timestamp from message contents.
-        aos::monotonic_clock::time_point capture_time(
-            target_estimate_fetchers_[camera_index]
-                .context()
-                .monotonic_remote_time -
-            monotonic_offset.value());
-        if (capture_time > target_estimate_fetchers_[camera_index]
-                               .context()
-                               .monotonic_event_time) {
-          model_based_.TallyRejection(RejectionReason::IMAGE_FROM_FUTURE);
-          continue;
+        if (target_estimate_fetchers_[camera_index].Fetch()) {
+          const std::optional<aos::monotonic_clock::duration> monotonic_offset =
+              ClockOffset(kPisToUse[camera_index]);
+          if (!monotonic_offset.has_value()) {
+            continue;
+          }
+          // TODO(james): Get timestamp from message contents.
+          aos::monotonic_clock::time_point capture_time(
+              target_estimate_fetchers_[camera_index]
+                  .context()
+                  .monotonic_remote_time -
+              monotonic_offset.value());
+          if (capture_time > target_estimate_fetchers_[camera_index]
+                                 .context()
+                                 .monotonic_event_time) {
+            model_based_.TallyRejection(RejectionReason::IMAGE_FROM_FUTURE);
+            continue;
+          }
+          capture_time -= pico_offset_error_;
+          model_based_.HandleImageMatch(
+              capture_time, target_estimate_fetchers_[camera_index].get(),
+              camera_index);
         }
-        capture_time -= pico_offset_error_;
-        model_based_.HandleImageMatch(
-            capture_time, target_estimate_fetchers_[camera_index].get(),
-            camera_index);
-      }
     }
   });
   event_loop_->OnRun([this, estimate_timer]() {
@@ -1098,6 +1109,7 @@
             output_builder.add_y(model_based_.xytheta()(1));
             output_builder.add_theta(model_based_.xytheta()(2));
             output_builder.add_zeroed(zeroer_.Zeroed());
+            output_builder.add_image_accepted_count(model_based_.total_accepted());
             const Eigen::Quaterniond &orientation = model_based_.orientation();
             Quaternion quaternion;
             quaternion.mutate_x(orientation.x());
diff --git a/y2022/localizer/localizer.h b/y2022/localizer/localizer.h
index f8205d7..fc15e9f 100644
--- a/y2022/localizer/localizer.h
+++ b/y2022/localizer/localizer.h
@@ -9,6 +9,7 @@
 #include "aos/network/message_bridge_server_generated.h"
 #include "aos/time/time.h"
 #include "frc971/control_loops/drivetrain/drivetrain_output_generated.h"
+#include "frc971/input/joystick_state_generated.h"
 #include "frc971/control_loops/drivetrain/improved_down_estimator.h"
 #include "frc971/control_loops/drivetrain/localizer_generated.h"
 #include "frc971/zeroing/imu_zeroer.h"
@@ -136,6 +137,9 @@
   AccelState accel_state() const { return current_state_.accel_state; };
 
   void set_longitudinal_offset(double offset) { long_offset_ = offset; }
+  void set_use_aggressive_image_corrections(bool aggressive) {
+    aggressive_corrections_ = aggressive;
+  }
 
   void TallyRejection(const RejectionReason reason);
 
@@ -146,6 +150,8 @@
 
   std::array<LedOutput, kNumPis> led_outputs() const { return led_outputs_; }
 
+  int total_accepted() const { return statistics_.total_accepted; }
+
  private:
   struct CombinedState {
     AccelState accel_state = AccelState::Zero();
@@ -267,6 +273,10 @@
   // center, negative = behind center.
   double long_offset_ = -0.15;
 
+  // Whether to use more aggressive corrections on the localizer. Only do this
+  // in teleop, since it can make spline control really jumpy.
+  bool aggressive_corrections_ = false;
+
   double last_residual_ = 0.0;
   double filtered_residual_ = 0.0;
   Eigen::Vector2d filtered_residual_accel_ = Eigen::Vector2d::Zero();
@@ -334,6 +344,7 @@
       target_estimate_fetchers_;
   aos::Fetcher<y2022::control_loops::superstructure::Status>
       superstructure_fetcher_;
+  aos::Fetcher<aos::JoystickState> joystick_state_fetcher_;
   zeroing::ImuZeroer zeroer_;
   aos::monotonic_clock::time_point last_output_send_ =
       aos::monotonic_clock::min_time;
diff --git a/y2022/localizer/localizer_output.fbs b/y2022/localizer/localizer_output.fbs
index ec3302a..ff25c31 100644
--- a/y2022/localizer/localizer_output.fbs
+++ b/y2022/localizer/localizer_output.fbs
@@ -34,6 +34,9 @@
   // Whether each led should be on.
   // Indices correspond to pi number.
   led_outputs:[LedOutput] (id: 6);
+
+  // Cumulative number of accepted images.
+  image_accepted_count:uint (id: 7);
 }
 
 root_type LocalizerOutput;
diff --git a/y2022/vision/BUILD b/y2022/vision/BUILD
index 65ab20c..6325234 100644
--- a/y2022/vision/BUILD
+++ b/y2022/vision/BUILD
@@ -317,6 +317,7 @@
     deps = [
         ":blob_detector_lib",
         ":calibration_data",
+        ":camera_reader_lib",
         ":target_estimator_lib",
         "//aos:init",
         "//aos/events:shm_event_loop",
diff --git a/y2022/vision/blob_detector.cc b/y2022/vision/blob_detector.cc
index 93c72d7..2f90513 100644
--- a/y2022/vision/blob_detector.cc
+++ b/y2022/vision/blob_detector.cc
@@ -133,7 +133,7 @@
   }
 
   // Threshold for mean distance from a blob centroid to a circle.
-  constexpr double kCircleDistanceThreshold = 1.0;
+  constexpr double kCircleDistanceThreshold = 2.0;
   // We should only expect to see blobs between these angles on a circle.
   constexpr double kDegToRad = M_PI / 180.0;
   constexpr double kMinBlobAngle = 50.0 * kDegToRad;
diff --git a/y2022/vision/calib_files/calibration_pi-9971-7_cam-22-07_2022-02-16_21-20-00.000000000.json b/y2022/vision/calib_files/calibration_pi-9971-4_cam-22-07_2022-02-16_21-20-00.000000000.json
similarity index 93%
rename from y2022/vision/calib_files/calibration_pi-9971-7_cam-22-07_2022-02-16_21-20-00.000000000.json
rename to y2022/vision/calib_files/calibration_pi-9971-4_cam-22-07_2022-02-16_21-20-00.000000000.json
index 6dbd3b2..fbc23f9 100755
--- a/y2022/vision/calib_files/calibration_pi-9971-7_cam-22-07_2022-02-16_21-20-00.000000000.json
+++ b/y2022/vision/calib_files/calibration_pi-9971-4_cam-22-07_2022-02-16_21-20-00.000000000.json
@@ -1,5 +1,5 @@
 {
- "node_name": "pi7",
+ "node_name": "pi4",
  "team_number": 9971,
  "intrinsics": [
   388.062378,
diff --git a/y2022/vision/calib_files/calibration_pi-9971-4_cam-22-04_2022-01-28_05-26-43.135661745.json b/y2022/vision/calib_files/calibration_pi-9971-7_cam-22-04_2022-01-28_05-26-43.135661745.json
similarity index 93%
rename from y2022/vision/calib_files/calibration_pi-9971-4_cam-22-04_2022-01-28_05-26-43.135661745.json
rename to y2022/vision/calib_files/calibration_pi-9971-7_cam-22-04_2022-01-28_05-26-43.135661745.json
index 5466224..3a964fc 100755
--- a/y2022/vision/calib_files/calibration_pi-9971-4_cam-22-04_2022-01-28_05-26-43.135661745.json
+++ b/y2022/vision/calib_files/calibration_pi-9971-7_cam-22-04_2022-01-28_05-26-43.135661745.json
@@ -1,5 +1,5 @@
 {
- "node_name": "pi4",
+ "node_name": "pi7",
  "team_number": 9971,
  "intrinsics": [
   386.619232,
diff --git a/y2022/vision/camera_definition.py b/y2022/vision/camera_definition.py
index f59f2cf..dbb23ad 100644
--- a/y2022/vision/camera_definition.py
+++ b/y2022/vision/camera_definition.py
@@ -90,7 +90,7 @@
     return base_cam_ext, turret_cam_ext
 
 
-def compute_extrinsic_by_pi(pi_number):
+def compute_extrinsic_by_pi(pi_number, team_number):
     # Defaults for all cameras
     camera_pitch = -35.0 * np.pi / 180.0
     camera_yaw = 0.0
@@ -98,18 +98,31 @@
     # Default camera location to robot origin
     T = np.array([0.0, 0.0, 0.0])
 
-    if pi_number == "pi1":
-        camera_yaw = 90.0 * np.pi / 180.0
-        T = np.array([-8.25 * 0.0254, 3.25 * 0.0254, 32.0 * 0.0254])
-    elif pi_number == "pi2":
-        camera_yaw = 0.0
-        T = np.array([-7.5 * 0.0254, -3.5 * 0.0254, 34.0 * 0.0254])
-    elif pi_number == "pi3":
-        camera_yaw = 178.5 * np.pi / 180.0
-        T = np.array([-1.0 * 0.0254, 8.5 * 0.0254, 34.25 * 0.0254])
-    elif pi_number == "pi4":
-        camera_yaw = -90.0 * np.pi / 180.0
-        T = np.array([-9.0 * 0.0254, -5 * 0.0254, 27.5 * 0.0254])
+    if team_number == 971:
+        if pi_number == "pi1":
+            camera_yaw = 90.0 * np.pi / 180.0
+            T = np.array([-11.0 * 0.0254, 5.5 * 0.0254, 29.5 * 0.0254])
+        elif pi_number == "pi2":
+            camera_yaw = 0.0
+            T = np.array([-9.5 * 0.0254, -3.5 * 0.0254, 34.5 * 0.0254])
+        elif pi_number == "pi3":
+            camera_yaw = 179.0 * np.pi / 180.0
+            T = np.array([-9.5 * 0.0254, 3.5 * 0.0254, 34.5 * 0.0254])
+        elif pi_number == "pi4":
+            camera_yaw = -90.0 * np.pi / 180.0
+            T = np.array([-10.25 * 0.0254, -5.0 * 0.0254, 27.5 * 0.0254])
+    elif team_number == 9971:
+        if pi_number == "pi1":
+            camera_yaw = 178.0 * np.pi / 180.0
+            T = np.array([0.0 * 0.0254, 8.5 * 0.0254, 34.0 * 0.0254])
+        elif pi_number == "pi2":
+            camera_yaw = 0.0
+            T = np.array([-9.0 * 0.0254, -3.5 * 0.0254, 35.5 * 0.0254])
+        elif pi_number == "pi3":
+            camera_yaw = 90.0 * np.pi / 180.0
+            T = np.array([-8.0 * 0.0254, 3.0 * 0.0254, 32.0 * 0.0254])
+    else:
+        glog.fatal("Unknown team number for extrinsics")
 
     return compute_extrinsic(camera_pitch, camera_yaw, T, is_turret)
 
@@ -155,7 +168,7 @@
             camera_params = CameraParameters()
             # TODO: Need to add reading in extrinsic camera parameters from json
             camera_params.camera_ext, camera_params.turret_ext = compute_extrinsic_by_pi(
-                node_name)
+                node_name, team_number)
 
             camera_params.node_name = node_name
             camera_params.team_number = team_number
diff --git a/y2022/vision/camera_reader.cc b/y2022/vision/camera_reader.cc
index 92d3727..0af4afc 100644
--- a/y2022/vision/camera_reader.cc
+++ b/y2022/vision/camera_reader.cc
@@ -21,12 +21,11 @@
 
 using namespace frc971::vision;
 
-const calibration::CameraCalibration *CameraReader::FindCameraCalibration()
-    const {
-  const std::string_view node_name = event_loop_->node()->name()->string_view();
-  const int team_number = aos::network::GetTeamNumber();
+const calibration::CameraCalibration *CameraReader::FindCameraCalibration(
+    const calibration::CalibrationData *calibration_data,
+    std::string_view node_name, int team_number) {
   for (const calibration::CameraCalibration *candidate :
-       *calibration_data_->camera_calibrations()) {
+       *calibration_data->camera_calibrations()) {
     if (candidate->node_name()->string_view() != node_name) {
       continue;
     }
@@ -92,9 +91,7 @@
 
 void CameraReader::ProcessImage(cv::Mat image_mat_distorted,
                                 int64_t image_monotonic_timestamp_ns) {
-  cv::Mat image_mat;
-  cv::undistort(image_mat_distorted, image_mat, CameraIntrinsics(),
-                CameraDistCoeffs());
+  cv::Mat image_mat = UndistortImage(image_mat_distorted, undistort_maps_);
 
   BlobDetector::BlobResult blob_result;
   BlobDetector::ExtractBlobs(image_mat, &blob_result);
diff --git a/y2022/vision/camera_reader.h b/y2022/vision/camera_reader.h
index 7128890..8317c09 100644
--- a/y2022/vision/camera_reader.h
+++ b/y2022/vision/camera_reader.h
@@ -29,12 +29,73 @@
 // TODO<jim>: Probably need to break out LED control to separate process
 class CameraReader {
  public:
+  static const calibration::CameraCalibration *FindCameraCalibration(
+      const calibration::CalibrationData *calibration_data,
+      std::string_view node_name, int team_number);
+
+  static cv::Mat CameraIntrinsics(
+      const calibration::CameraCalibration *camera_calibration) {
+    cv::Mat result(3, 3, CV_32F,
+                   const_cast<void *>(static_cast<const void *>(
+                       camera_calibration->intrinsics()->data())));
+    result.convertTo(result, CV_64F);
+    CHECK_EQ(result.total(), camera_calibration->intrinsics()->size());
+    return result;
+  }
+
+  static cv::Mat CameraExtrinsics(
+      const calibration::CameraCalibration *camera_calibration) {
+    // TODO(james): What's the principled way to handle non-z-axis turrets?
+    const frc971::vision::calibration::TransformationMatrix *transform =
+        camera_calibration->has_turret_extrinsics()
+            ? camera_calibration->turret_extrinsics()
+            : camera_calibration->fixed_extrinsics();
+
+    cv::Mat result(4, 4, CV_32F,
+                   const_cast<void *>(
+                       static_cast<const void *>(transform->data()->data())));
+    result.convertTo(result, CV_64F);
+    CHECK_EQ(result.total(), transform->data()->size());
+    return result;
+  }
+
+  static cv::Mat CameraDistCoeffs(
+      const calibration::CameraCalibration *camera_calibration) {
+    const cv::Mat result(5, 1, CV_32F,
+                         const_cast<void *>(static_cast<const void *>(
+                             camera_calibration->dist_coeffs()->data())));
+    CHECK_EQ(result.total(), camera_calibration->dist_coeffs()->size());
+    return result;
+  }
+
+  static std::pair<cv::Mat, cv::Mat> ComputeUndistortMaps(
+      const cv::Mat intrinsics, const cv::Mat dist_coeffs) {
+    std::pair<cv::Mat, cv::Mat> undistort_maps;
+    static const cv::Size kImageSize = {640, 480};
+    cv::initUndistortRectifyMap(intrinsics, dist_coeffs, cv::Mat(), intrinsics,
+                                kImageSize, CV_16SC2, undistort_maps.first,
+                                undistort_maps.second);
+    return undistort_maps;
+  }
+
+  static cv::Mat UndistortImage(cv::Mat image_distorted,
+                                std::pair<cv::Mat, cv::Mat> undistort_maps) {
+    cv::Mat image;
+    cv::remap(image_distorted, image, undistort_maps.first,
+              undistort_maps.second, cv::INTER_LINEAR);
+    return image;
+  }
+
   CameraReader(aos::ShmEventLoop *event_loop,
                const calibration::CalibrationData *calibration_data,
                V4L2Reader *reader)
       : event_loop_(event_loop),
         calibration_data_(calibration_data),
-        camera_calibration_(FindCameraCalibration()),
+        camera_calibration_(FindCameraCalibration(
+            calibration_data_, event_loop_->node()->name()->string_view(),
+            aos::network::GetTeamNumber())),
+        undistort_maps_(
+            ComputeUndistortMaps(CameraIntrinsics(), CameraDistCoeffs())),
         reader_(reader),
         image_sender_(event_loop->MakeSender<CameraImage>("/camera")),
         target_estimator_(CameraIntrinsics(), CameraExtrinsics()),
@@ -60,8 +121,6 @@
   double GetDutyCycle() { return duty_cycle_; }
 
  private:
-  const calibration::CameraCalibration *FindCameraCalibration() const;
-
   // Processes an image (including sending the results).
   void ProcessImage(cv::Mat image_mat_distorted,
                     int64_t image_monotonic_timestamp_ns);
@@ -70,40 +129,21 @@
   void ReadImage();
 
   cv::Mat CameraIntrinsics() const {
-    cv::Mat result(3, 3, CV_32F,
-                   const_cast<void *>(static_cast<const void *>(
-                       camera_calibration_->intrinsics()->data())));
-    result.convertTo(result, CV_64F);
-    CHECK_EQ(result.total(), camera_calibration_->intrinsics()->size());
-    return result;
+    return CameraIntrinsics(camera_calibration_);
   }
 
   cv::Mat CameraExtrinsics() const {
-    // TODO(james): What's the principled way to handle non-z-axis turrets?
-    const frc971::vision::calibration::TransformationMatrix *transform =
-        camera_calibration_->has_turret_extrinsics()
-            ? camera_calibration_->turret_extrinsics()
-            : camera_calibration_->fixed_extrinsics();
-
-    cv::Mat result(4, 4, CV_32F,
-                   const_cast<void *>(
-                       static_cast<const void *>(transform->data()->data())));
-    result.convertTo(result, CV_64F);
-    CHECK_EQ(result.total(), transform->data()->size());
-    return result;
+    return CameraExtrinsics(camera_calibration_);
   }
 
   cv::Mat CameraDistCoeffs() const {
-    const cv::Mat result(5, 1, CV_32F,
-                         const_cast<void *>(static_cast<const void *>(
-                             camera_calibration_->dist_coeffs()->data())));
-    CHECK_EQ(result.total(), camera_calibration_->dist_coeffs()->size());
-    return result;
+    return CameraDistCoeffs(camera_calibration_);
   }
 
   aos::ShmEventLoop *const event_loop_;
   const calibration::CalibrationData *const calibration_data_;
   const calibration::CameraCalibration *const camera_calibration_;
+  std::pair<cv::Mat, cv::Mat> undistort_maps_;
   V4L2Reader *const reader_;
   aos::Sender<CameraImage> image_sender_;
   TargetEstimator target_estimator_;
diff --git a/y2022/vision/camera_reader_main.cc b/y2022/vision/camera_reader_main.cc
index 9320152..bfd1c7d 100644
--- a/y2022/vision/camera_reader_main.cc
+++ b/y2022/vision/camera_reader_main.cc
@@ -7,10 +7,10 @@
 //   --override_hostname pi-7971-1  --ignore_timestamps true
 DECLARE_bool(use_outdoors);
 DEFINE_string(config, "aos_config.json", "Path to the config file to use.");
-DEFINE_double(duty_cycle, 0.6, "Duty cycle of the LEDs");
+DEFINE_double(duty_cycle, 0.65, "Duty cycle of the LEDs");
 DEFINE_uint32(exposure, 5,
               "Exposure time, in 100us increments; 0 implies auto exposure");
-DEFINE_uint32(outdoors_exposure, 20,
+DEFINE_uint32(outdoors_exposure, 4,
               "Exposure time when using --use_outdoors, in 100us increments; 0 "
               "implies auto exposure");
 
diff --git a/y2022/vision/target_estimator.cc b/y2022/vision/target_estimator.cc
index 9eef390..1447d81 100644
--- a/y2022/vision/target_estimator.cc
+++ b/y2022/vision/target_estimator.cc
@@ -378,16 +378,16 @@
       [](const std::pair<size_t, size_t> &a,
          const std::pair<size_t, size_t> &b) { return a.first < b.first; });
 
-  size_t middle_tape_index = 1000;
+  std::optional<size_t> middle_tape_index = std::nullopt;
   for (size_t i = 0; i < tape_indices.size(); ++i) {
     if (tape_indices[i].second == middle_blob_index_) {
       middle_tape_index = i;
     }
   }
-  CHECK_NE(middle_tape_index, 1000) << "Failed to find middle tape";
+  CHECK(middle_tape_index.has_value()) << "Failed to find middle tape";
 
   if (VLOG_IS_ON(2)) {
-    LOG(INFO) << "Middle tape is " << middle_tape_index << ", blob "
+    LOG(INFO) << "Middle tape is " << *middle_tape_index << ", blob "
               << middle_blob_index_;
     for (size_t i = 0; i < tape_indices.size(); ++i) {
       const auto distance = DistanceFromTapeIndex(
@@ -400,7 +400,7 @@
 
   {
     size_t offset = 0;
-    for (size_t i = middle_tape_index + 1; i < tape_indices.size(); ++i) {
+    for (size_t i = *middle_tape_index + 1; i < tape_indices.size(); ++i) {
       tape_indices[i].first -= offset;
 
       if (tape_indices[i].first > tape_indices[i - 1].first + 1) {
@@ -412,7 +412,7 @@
   }
 
   if (VLOG_IS_ON(2)) {
-    LOG(INFO) << "Middle tape is " << middle_tape_index << ", blob "
+    LOG(INFO) << "Middle tape is " << *middle_tape_index << ", blob "
               << middle_blob_index_;
     for (size_t i = 0; i < tape_indices.size(); ++i) {
       const auto distance = DistanceFromTapeIndex(
@@ -425,7 +425,7 @@
 
   {
     size_t offset = 0;
-    for (size_t i = middle_tape_index; i > 0; --i) {
+    for (size_t i = *middle_tape_index; i > 0; --i) {
       tape_indices[i - 1].first -= offset;
 
       if (tape_indices[i - 1].first + 1 < tape_indices[i].first) {
@@ -440,7 +440,7 @@
   }
 
   if (VLOG_IS_ON(2)) {
-    LOG(INFO) << "Middle tape is " << middle_tape_index << ", blob "
+    LOG(INFO) << "Middle tape is " << *middle_tape_index << ", blob "
               << middle_blob_index_;
     for (size_t i = 0; i < tape_indices.size(); ++i) {
       const auto distance = DistanceFromTapeIndex(
@@ -566,11 +566,11 @@
     size_t blob_index, const std::vector<cv::Point_<S>> &tape_points) const {
   auto distance = cv::Point_<S>(std::numeric_limits<S>::infinity(),
                                 std::numeric_limits<S>::infinity());
-  size_t final_match = 255;
+  std::optional<size_t> final_match = std::nullopt;
   if (blob_index == middle_blob_index_) {
     // Fix the middle blob so the solver can't go too far off
     final_match = tape_points.size() / 2;
-    distance = DistanceFromTapeIndex(blob_index, final_match, tape_points);
+    distance = DistanceFromTapeIndex(blob_index, *final_match, tape_points);
   } else {
     // Give the other blob_stats some freedom in case some are split into pieces
     for (auto it = tape_points.begin(); it < tape_points.end(); it++) {
@@ -585,11 +585,11 @@
     }
   }
 
-  VLOG(2) << "Matched index " << blob_index << " to " << final_match
+  CHECK(final_match.has_value());
+  VLOG(2) << "Matched index " << blob_index << " to " << *final_match
           << " distance " << distance.x << " " << distance.y;
-  CHECK_NE(final_match, 255);
 
-  return final_match;
+  return *final_match;
 }
 
 void TargetEstimator::DrawProjectedHub(
diff --git a/y2022/vision/viewer.cc b/y2022/vision/viewer.cc
index 446f1f6..e455f66 100644
--- a/y2022/vision/viewer.cc
+++ b/y2022/vision/viewer.cc
@@ -13,6 +13,7 @@
 #include "frc971/vision/vision_generated.h"
 #include "y2022/vision/blob_detector.h"
 #include "y2022/vision/calibration_data.h"
+#include "y2022/vision/camera_reader.h"
 #include "y2022/vision/target_estimate_generated.h"
 #include "y2022/vision/target_estimator.h"
 
@@ -213,51 +214,24 @@
   const aos::FlatbufferSpan<calibration::CalibrationData> calibration_data(
       CalibrationData());
 
-  const calibration::CameraCalibration *calibration = nullptr;
-  for (const calibration::CameraCalibration *candidate :
-       *calibration_data.message().camera_calibrations()) {
-    if ((candidate->node_name()->string_view() == FLAGS_calibration_node) &&
-        (candidate->team_number() == FLAGS_calibration_team_number)) {
-      calibration = candidate;
-      break;
-    }
-  }
+  const calibration::CameraCalibration *calibration =
+      CameraReader::FindCameraCalibration(&calibration_data.message(),
+                                          FLAGS_calibration_node,
+                                          FLAGS_calibration_team_number);
+  const auto intrinsics = CameraReader::CameraIntrinsics(calibration);
+  const auto extrinsics = CameraReader::CameraExtrinsics(calibration);
+  const auto dist_coeffs = CameraReader::CameraDistCoeffs(calibration);
 
-  CHECK(calibration) << "No calibration data found for node \""
-                     << FLAGS_calibration_node << "\" with team number "
-                     << FLAGS_calibration_team_number;
-
-  const auto intrinsics_float = cv::Mat(
-      3, 3, CV_32F,
-      const_cast<void *>(
-          static_cast<const void *>(calibration->intrinsics()->data())));
-  cv::Mat intrinsics;
-  intrinsics_float.convertTo(intrinsics, CV_64F);
-
-  const frc971::vision::calibration::TransformationMatrix *transform =
-      calibration->has_turret_extrinsics() ? calibration->turret_extrinsics()
-                                           : calibration->fixed_extrinsics();
-
-  const auto extrinsics_float = cv::Mat(
-      4, 4, CV_32F,
-      const_cast<void *>(static_cast<const void *>(transform->data()->data())));
-  cv::Mat extrinsics;
-  extrinsics_float.convertTo(extrinsics, CV_64F);
-
-  const auto dist_coeffs_float = cv::Mat(
-      5, 1, CV_32F,
-      const_cast<void *>(
-          static_cast<const void *>(calibration->dist_coeffs()->data())));
-  cv::Mat dist_coeffs;
-  dist_coeffs_float.convertTo(dist_coeffs, CV_64F);
+  // Compute undistortion map once for efficiency
+  const auto undistort_maps =
+      CameraReader::ComputeUndistortMaps(intrinsics, dist_coeffs);
 
   TargetEstimator estimator(intrinsics, extrinsics);
 
   for (auto it = file_list.begin() + FLAGS_skip; it < file_list.end(); it++) {
     LOG(INFO) << "Reading file " << (it - file_list.begin()) << ": " << *it;
-    cv::Mat image_mat_distorted = cv::imread(it->c_str());
-    cv::Mat image_mat;
-    cv::undistort(image_mat_distorted, image_mat, intrinsics, dist_coeffs);
+    cv::Mat image_mat =
+        CameraReader::UndistortImage(cv::imread(it->c_str()), undistort_maps);
 
     BlobDetector::BlobResult blob_result;
     blob_result.binarized_image =
diff --git a/y2022/vision/viewer_replay.cc b/y2022/vision/viewer_replay.cc
index b2d3464..5d09d55 100644
--- a/y2022/vision/viewer_replay.cc
+++ b/y2022/vision/viewer_replay.cc
@@ -194,6 +194,8 @@
 
         bool use_image = true;
         if (FLAGS_detected_only || FLAGS_filtered_only) {
+          // TODO(milind): if adding target estimation here in the future,
+          // undistortion is needed
           BlobDetector::BlobResult blob_result;
           BlobDetector::ExtractBlobs(image_mat, &blob_result);
 
diff --git a/y2022/www/BUILD b/y2022/www/BUILD
index 88e54a5..957045d 100644
--- a/y2022/www/BUILD
+++ b/y2022/www/BUILD
@@ -24,6 +24,7 @@
         "//aos/network:connect_ts_fbs",
         "//aos/network:web_proxy_ts_fbs",
         "//aos/network/www:proxy",
+        "//frc971/control_loops/drivetrain:drivetrain_status_ts_fbs",
         "//y2022/control_loops/superstructure:superstructure_status_ts_fbs",
         "//y2022/localizer:localizer_output_ts_fbs",
         "//y2022/localizer:localizer_status_ts_fbs",
diff --git a/y2022/www/field_handler.ts b/y2022/www/field_handler.ts
index 22989be..a9aab6a 100644
--- a/y2022/www/field_handler.ts
+++ b/y2022/www/field_handler.ts
@@ -1,9 +1,10 @@
 import {ByteBuffer} from 'flatbuffers';
-import {Connection} from 'org_frc971/aos/network/www/proxy';
-import {IntakeState, Status as SuperstructureStatus, SuperstructureState} from 'org_frc971/y2022/control_loops/superstructure/superstructure_status_generated'
-import {LocalizerOutput} from 'org_frc971/y2022/localizer/localizer_output_generated';
-import {RejectionReason} from 'org_frc971/y2022/localizer/localizer_status_generated';
-import {LocalizerVisualization, TargetEstimateDebug} from 'org_frc971/y2022/localizer/localizer_visualization_generated';
+import {Connection} from '../../aos/network/www/proxy';
+import {IntakeState, Status as SuperstructureStatus, SuperstructureState} from '../control_loops/superstructure/superstructure_status_generated'
+import {LocalizerOutput} from '../localizer/localizer_output_generated';
+import {RejectionReason} from '../localizer/localizer_status_generated';
+import {Status as DrivetrainStatus} from '../../frc971/control_loops/drivetrain/drivetrain_status_generated';
+import {LocalizerVisualization, TargetEstimateDebug} from '../localizer/localizer_visualization_generated';
 
 import {FIELD_LENGTH, FIELD_WIDTH, FT_TO_M, IN_TO_M} from './constants';
 
@@ -19,6 +20,7 @@
 export class FieldHandler {
   private canvas = document.createElement('canvas');
   private localizerOutput: LocalizerOutput|null = null;
+  private drivetrainStatus: DrivetrainStatus|null = null;
   private superstructureStatus: SuperstructureStatus|null = null;
 
   // Image information indexed by timestamp (seconds since the epoch), so that
@@ -105,6 +107,10 @@
             this.handleLocalizerDebug(data);
           });
       this.connection.addHandler(
+          '/drivetrain', DrivetrainStatus.getFullyQualifiedName(), (data) => {
+            this.handleDrivetrainStatus(data);
+          });
+      this.connection.addHandler(
           '/localizer', LocalizerOutput.getFullyQualifiedName(), (data) => {
             this.handleLocalizerOutput(data);
           });
@@ -154,6 +160,11 @@
     this.localizerOutput = LocalizerOutput.getRootAsLocalizerOutput(fbBuffer);
   }
 
+  private handleDrivetrainStatus(data: Uint8Array): void {
+    const fbBuffer = new ByteBuffer(data);
+    this.drivetrainStatus = DrivetrainStatus.getRootAsStatus(fbBuffer);
+  }
+
   private handleSuperstructureStatus(data: Uint8Array): void {
     const fbBuffer = new ByteBuffer(data);
     this.superstructureStatus = SuperstructureStatus.getRootAsStatus(fbBuffer);
@@ -161,9 +172,12 @@
 
   drawField(): void {
     const ctx = this.canvas.getContext('2d');
+    ctx.save();
+    ctx.scale(-1.0, 1.0);
     ctx.drawImage(
         this.fieldImage, 0, 0, this.fieldImage.width, this.fieldImage.height,
         -FIELD_EDGE_X, -FIELD_SIDE_Y, FIELD_LENGTH, FIELD_WIDTH);
+    ctx.restore();
   }
 
   drawCamera(
@@ -377,6 +391,14 @@
       }
     }
 
+    if (this.drivetrainStatus && this.drivetrainStatus.trajectoryLogging()) {
+      this.drawRobot(
+          this.drivetrainStatus.trajectoryLogging().x(),
+          this.drivetrainStatus.trajectoryLogging().y(),
+          this.drivetrainStatus.trajectoryLogging().theta(), null, "#000000FF",
+          false);
+    }
+
     if (this.localizerOutput) {
       if (!this.localizerOutput.zeroed()) {
         this.setZeroing(this.x);
diff --git a/y2022/y2022_logger.json b/y2022/y2022_logger.json
index f54ccd7..6c33eec 100644
--- a/y2022/y2022_logger.json
+++ b/y2022/y2022_logger.json
@@ -42,7 +42,7 @@
       ]
     },
     {
-      "name": "/aos/remote_timestamps/roborio/superstructure/y2022-vision-BallColor",
+      "name": "/logger/aos/remote_timestamps/roborio/superstructure/y2022-vision-BallColor",
       "type": "aos.message_bridge.RemoteMessage",
       "source_node": "logger",
       "logger": "NOT_LOGGED",
@@ -391,12 +391,22 @@
     {
       "name": "/logger/camera",
       "type": "frc971.vision.CameraImage",
+      "logger": "NOT_LOGGED",
       "source_node": "logger",
       "frequency": 100,
       "max_size": 620000,
       "num_senders": 1
     },
     {
+      "name": "/logger/camera/decimated",
+      "type": "frc971.vision.CameraImage",
+      "source_node": "logger",
+      "logger": "LOCAL_LOGGER",
+      "frequency": 2,
+      "max_size": 620000,
+      "num_senders": 2
+    },
+    {
       "name": "/pi1/camera/decimated",
       "type": "frc971.vision.CameraImage",
       "source_node": "pi1",
@@ -473,7 +483,7 @@
     },
     {
       "match": {
-        "name": "/camera",
+        "name": "/camera*",
         "source_node": "logger"
       },
       "rename": {
@@ -520,6 +530,13 @@
       "nodes": [
         "logger"
       ]
+    },
+    {
+      "name": "image_decimator",
+      "executable_name": "image_decimator",
+      "nodes": [
+        "logger"
+      ]
     }
   ],
   "nodes": [
diff --git a/y2022/y2022_pi_template.json b/y2022/y2022_pi_template.json
index bcd3f6b..a2c4b88 100644
--- a/y2022/y2022_pi_template.json
+++ b/y2022/y2022_pi_template.json
@@ -156,7 +156,7 @@
       "name": "/pi{{ NUM }}/camera",
       "type": "frc971.vision.CameraImage",
       "source_node": "pi{{ NUM }}",
-      "frequency": 25,
+      "frequency": 40,
       "max_size": 620000,
       "num_senders": 18
     },
@@ -166,7 +166,7 @@
       "source_node": "pi{{ NUM }}",
       "frequency": 2,
       "max_size": 620000,
-      "num_senders": 18
+      "num_senders": 2
     },
     {
       "name": "/pi{{ NUM }}/camera",
@@ -179,7 +179,7 @@
       "name": "/pi{{ NUM }}/camera",
       "type": "y2022.vision.TargetEstimate",
       "source_node": "pi{{ NUM }}",
-      "frequency": 25,
+      "frequency": 40,
       "num_senders": 2,
       "max_size": 40000,
       "logger": "LOCAL_AND_REMOTE_LOGGER",
@@ -211,14 +211,14 @@
     {
       "name": "/pi{{ NUM }}/aos/remote_timestamps/imu/pi{{ NUM }}/camera/y2022-vision-TargetEstimate",
       "type": "aos.message_bridge.RemoteMessage",
-      "frequency": 40,
+      "frequency": 50,
       "source_node": "pi{{ NUM }}",
       "max_size": 208
     },
     {
       "name": "/pi{{ NUM }}/aos/remote_timestamps/logger/pi{{ NUM }}/camera/y2022-vision-TargetEstimate",
       "type": "aos.message_bridge.RemoteMessage",
-      "frequency": 40,
+      "frequency": 50,
       "source_node": "pi{{ NUM }}",
       "max_size": 208
     },
diff --git a/y2022/y2022_roborio.json b/y2022/y2022_roborio.json
index 8651923..1a5a1aa 100644
--- a/y2022/y2022_roborio.json
+++ b/y2022/y2022_roborio.json
@@ -7,7 +7,8 @@
       "frequency": 100,
       "logger": "LOCAL_AND_REMOTE_LOGGER",
       "logger_nodes" : [
-        "imu"
+        "imu",
+        "logger"
       ],
       "destination_nodes": [
         {
@@ -18,6 +19,15 @@
           "timestamp_logger_nodes": [
             "roborio"
           ]
+        },
+        {
+          "name": "logger",
+          "priority": 5,
+          "time_to_live": 50000000,
+          "timestamp_logger": "LOCAL_AND_REMOTE_LOGGER",
+          "timestamp_logger_nodes": [
+            "roborio"
+          ]
         }
       ]
     },
@@ -31,6 +41,15 @@
       "max_size": 200
     },
     {
+      "name": "/roborio/aos/remote_timestamps/logger/roborio/aos/aos-JoystickState",
+      "type": "aos.message_bridge.RemoteMessage",
+      "source_node": "roborio",
+      "logger": "NOT_LOGGED",
+      "frequency": 200,
+      "num_senders": 2,
+      "max_size": 200
+    },
+    {
       "name": "/roborio/aos",
       "type": "aos.RobotState",
       "source_node": "roborio",