diff --git a/pkg/apis/autoscaling/v1/fleetautoscaler.go b/pkg/apis/autoscaling/v1/fleetautoscaler.go
index a8e1fe2de3..14e96c4447 100644
--- a/pkg/apis/autoscaling/v1/fleetautoscaler.go
+++ b/pkg/apis/autoscaling/v1/fleetautoscaler.go
@@ -581,13 +581,9 @@ func (c *ChainPolicy) ValidateChainPolicy(fldPath *field.Path) field.ErrorList {
 			seenIDs[entry.ID] = true
 		}
 		// Ensure that chain entry has a policy
-		hasValidPolicy := entry.Buffer == nil && entry.Webhook == nil && entry.Counter == nil && entry.List == nil && entry.Schedule == nil
-		if entry.Type == "" || hasValidPolicy {
-			allErrs = append(allErrs, field.Required(fldPath.Index(i), "policy is missing"))
-		}
-		// Ensure the chain entry's policy is not a chain policy (to avoid nested chain policies)
-		if entry.Chain != nil {
-			allErrs = append(allErrs, field.Invalid(fldPath.Index(i), entry.FleetAutoscalerPolicy.Type, "chain policy cannot be used in chain policy"))
+		hasValidPolicy := entry.Buffer != nil || entry.Webhook != nil || entry.Counter != nil || entry.List != nil || entry.Schedule != nil
+		if entry.Type == "" || !hasValidPolicy {
+			allErrs = append(allErrs, field.Required(fldPath.Index(i), "valid policy is missing"))
 		}
 		// Validate the chain entry's policy
 		allErrs = append(allErrs, entry.FleetAutoscalerPolicy.ValidatePolicy(fldPath.Index(i).Child("policy"))...)
diff --git a/pkg/apis/autoscaling/v1/fleetautoscaler_test.go b/pkg/apis/autoscaling/v1/fleetautoscaler_test.go
index 957cd07153..60a73786f9 100644
--- a/pkg/apis/autoscaling/v1/fleetautoscaler_test.go
+++ b/pkg/apis/autoscaling/v1/fleetautoscaler_test.go
@@ -488,8 +488,6 @@ func TestFleetAutoscalerScheduleValidateUpdate(t *testing.T) {
 		},
 		"end time before start time": {
 			fas: modifiedFAS(func(fap *FleetAutoscalerPolicy) {
-				mustParseDate("3999-06-15T15:59:59Z")
-				mustParseDate("3999-05-15T15:59:59Z")
 				fap.Schedule.Between.Start = mustParseDate("3999-06-15T15:59:59Z")
 				fap.Schedule.Between.End = mustParseDate("3999-05-15T15:59:59Z")
 			}),
@@ -588,7 +586,7 @@ func TestFleetAutoscalerChainValidateUpdate(t *testing.T) {
 				}
 			}),
 			featureFlags: string(runtime.FeatureScheduledAutoscaler) + "=true",
-			wantLength:   2,
+			wantLength:   1,
 			wantField:    "spec.policy.chain[1]",
 		},
 		"invalid nested policy format": {
diff --git a/pkg/fleetautoscalers/controller.go b/pkg/fleetautoscalers/controller.go
index bfbfb5e4ed..abc1e61945 100644
--- a/pkg/fleetautoscalers/controller.go
+++ b/pkg/fleetautoscalers/controller.go
@@ -313,8 +313,9 @@ func (c *Controller) syncFleetAutoscaler(ctx context.Context, key string) error
 	}
 
 	currentReplicas := fleet.Status.Replicas
-	desiredReplicas, scalingLimited, err := computeDesiredFleetSize(fas, fleet, c.gameServerLister, c.counter.Counts())
-	if err != nil {
+	desiredReplicas, scalingLimited, err := computeDesiredFleetSize(fas.Spec.Policy, fleet, c.gameServerLister, c.counter.Counts())
+	// If there err is nil and not an inactive schedule error (ignorable in this case), then record the event
+	if err != nil && !errors.Is(err, InactiveScheduleError{}) {
 		c.recorder.Eventf(fas, corev1.EventTypeWarning, "FleetAutoscaler",
 			"Error calculating desired fleet size on FleetAutoscaler %s. Error: %s", fas.ObjectMeta.Name, err.Error())
 
diff --git a/pkg/fleetautoscalers/fleetautoscalers.go b/pkg/fleetautoscalers/fleetautoscalers.go
index 5ef77e0edf..8663be99ad 100644
--- a/pkg/fleetautoscalers/fleetautoscalers.go
+++ b/pkg/fleetautoscalers/fleetautoscalers.go
@@ -29,6 +29,7 @@ import (
 	"time"
 
 	"github.com/pkg/errors"
+	"github.com/robfig/cron/v3"
 	"k8s.io/apimachinery/pkg/util/intstr"
 	"k8s.io/apimachinery/pkg/util/uuid"
 
@@ -41,6 +42,8 @@ import (
 	"agones.dev/agones/pkg/util/runtime"
 )
 
+const maxDuration = "2540400h" // 290 Years
+
 var tlsConfig = &tls.Config{}
 var client = http.Client{
 	Timeout: 15 * time.Second,
@@ -49,18 +52,29 @@ var client = http.Client{
 	},
 }
 
+// InactiveScheduleError denotes an error for schedules that are not currently active.
+type InactiveScheduleError struct{}
+
+func (InactiveScheduleError) Error() string {
+	return "inactive schedule, policy not applicable"
+}
+
 // computeDesiredFleetSize computes the new desired size of the given fleet
-func computeDesiredFleetSize(fas *autoscalingv1.FleetAutoscaler, f *agonesv1.Fleet,
+func computeDesiredFleetSize(pol autoscalingv1.FleetAutoscalerPolicy, f *agonesv1.Fleet,
 	gameServerLister listeragonesv1.GameServerLister, nodeCounts map[string]gameservers.NodeCount) (int32, bool, error) {
-	switch fas.Spec.Policy.Type {
+	switch pol.Type {
 	case autoscalingv1.BufferPolicyType:
-		return applyBufferPolicy(fas.Spec.Policy.Buffer, f)
+		return applyBufferPolicy(pol.Buffer, f)
 	case autoscalingv1.WebhookPolicyType:
-		return applyWebhookPolicy(fas.Spec.Policy.Webhook, f)
+		return applyWebhookPolicy(pol.Webhook, f)
 	case autoscalingv1.CounterPolicyType:
-		return applyCounterOrListPolicy(fas.Spec.Policy.Counter, nil, f, gameServerLister, nodeCounts)
+		return applyCounterOrListPolicy(pol.Counter, nil, f, gameServerLister, nodeCounts)
 	case autoscalingv1.ListPolicyType:
-		return applyCounterOrListPolicy(nil, fas.Spec.Policy.List, f, gameServerLister, nodeCounts)
+		return applyCounterOrListPolicy(nil, pol.List, f, gameServerLister, nodeCounts)
+	case autoscalingv1.SchedulePolicyType:
+		return applySchedulePolicy(pol.Schedule, f, gameServerLister, nodeCounts, time.Now())
+	case autoscalingv1.ChainPolicyType:
+		return applyChainPolicy(pol.Chain, f, gameServerLister, nodeCounts, time.Now())
 	}
 
 	return 0, false, errors.New("wrong policy type, should be one of: Buffer, Webhook, Counter, List")
@@ -362,6 +376,110 @@ func applyCounterOrListPolicy(c *autoscalingv1.CounterPolicy, l *autoscalingv1.L
 	return 0, false, errors.Errorf("unable to apply ListPolicy %v", l)
 }
 
+func applySchedulePolicy(s *autoscalingv1.SchedulePolicy, f *agonesv1.Fleet, gameServerLister listeragonesv1.GameServerLister, nodeCounts map[string]gameservers.NodeCount, currentTime time.Time) (int32, bool, error) {
+	// Ensure the scheduled autoscaler feature gate is enabled
+	if !runtime.FeatureEnabled(runtime.FeatureScheduledAutoscaler) {
+		return 0, false, errors.Errorf("cannot apply SchedulePolicy unless feature flag %s is enabled", runtime.FeatureScheduledAutoscaler)
+	}
+
+	if isScheduleActive(s, currentTime) {
+		return computeDesiredFleetSize(s.Policy, f, gameServerLister, nodeCounts)
+	}
+
+	// If the schedule wasn't active then return the current replica amount of the fleet
+	return f.Status.Replicas, false, &InactiveScheduleError{}
+}
+
+func applyChainPolicy(c autoscalingv1.ChainPolicy, f *agonesv1.Fleet, gameServerLister listeragonesv1.GameServerLister, nodeCounts map[string]gameservers.NodeCount, currentTime time.Time) (int32, bool, error) {
+	// Ensure the scheduled autoscaler feature gate is enabled
+	if !runtime.FeatureEnabled(runtime.FeatureScheduledAutoscaler) {
+		return 0, false, errors.Errorf("cannot apply ChainPolicy unless feature flag %s is enabled", runtime.FeatureScheduledAutoscaler)
+	}
+
+	replicas := f.Status.Replicas
+	var limited bool
+	var err error
+
+	// Loop over all entries in the chain
+	for _, entry := range c {
+		switch entry.Type {
+		case autoscalingv1.SchedulePolicyType:
+			replicas, limited, err = applySchedulePolicy(entry.Schedule, f, gameServerLister, nodeCounts, currentTime)
+			// If no error was returned from the schedule policy (schedule is active and/or webhook policy within schedule was successful), then return the values given
+			if err == nil {
+				return replicas, limited, nil
+			}
+		case autoscalingv1.WebhookPolicyType:
+			replicas, limited, err = applyWebhookPolicy(entry.Webhook, f)
+			// If no error was returned from the webhook policy, then return the values given
+			if err == nil {
+				return replicas, limited, nil
+			}
+		default:
+			// Every other policy type we just want to compute the desired fleet and return it
+			return computeDesiredFleetSize(entry.FleetAutoscalerPolicy, f, gameServerLister, nodeCounts)
+		}
+
+	}
+
+	// Fall off the chain
+	return replicas, limited, err
+}
+
+// isScheduleActive checks if a chain entry's is active and returns a boolean, true if active, false otherwise
+func isScheduleActive(s *autoscalingv1.SchedulePolicy, currentTime time.Time) bool {
+	// Used for checking ahead of the schedule for daylight savings purposes
+	cronDelta := (time.Minute * -1) + (time.Second * -30)
+
+	// If the current time is before the start time, the schedule is inactive so return false
+	startTime := s.Between.Start.Time
+	if currentTime.Before(startTime) {
+		return false
+	}
+
+	// If an end time is present and the current time is after the end time, the schedule is inactive so return false
+	endTime := s.Between.End.Time
+	if !endTime.IsZero() && currentTime.After(endTime) {
+		return false
+	}
+
+	// If no startCron field is specified, then it's automatically true (duration is no longer relevant since we're always running)
+	if s.ActivePeriod.StartCron == "" {
+		return true
+	}
+
+	// Ignore the error as validation is already done within the validateChainPolicy after being unmarshalled
+	location, _ := time.LoadLocation(s.ActivePeriod.Timezone)
+
+	// Ignore the error as validation is already done within the validateChainPolicy after being unmarshalled
+	startCron, _ := cron.ParseStandard(s.ActivePeriod.StartCron)
+
+	// Ignore the error as validation is already done within the validateChainPolicy after being unmarshalled.
+	// If the duration is empty set it to the largest duration possible (290 years)
+	duration, _ := time.ParseDuration(s.ActivePeriod.Duration)
+	if s.ActivePeriod.Duration == "" {
+		duration, _ = time.ParseDuration(maxDuration)
+	}
+
+	// Get the current time - duration
+	currentTimeMinusDuration := currentTime.Add(duration * -1)
+	// Take (current time - duration) to get the first available start time
+	cronStartTime := startCron.Next(currentTimeMinusDuration.In(location))
+	// Take the (cronStartTime + duration) to get the end time
+	cronEndTime := cronStartTime.Add(duration)
+
+	// If the current time is after the cronStartTime - 90 seconds (for daylight saving purposes) AND the current time before the cronEndTime
+	// then return true
+	// Example: startCron = 0 14 * * * // 2:00 PM Everyday | duration = 1 hr | cronDelta = 90 seconds | currentTime = 2024-08-01T14:30:00Z | currentTimeMinusDuration = 2024-08-01T13:30:00Z
+	// then cronStartTime = 2024-08-01T14:00:00Z and cronEndTime = 2024-08-01T15:00:00Z
+	// and since currentTime > cronStartTime + cronDelta AND currentTime < cronEndTime, we return true
+	if currentTime.After(cronStartTime.Add(cronDelta)) && currentTime.Before(cronEndTime) {
+		return true
+	}
+
+	return false
+}
+
 // getSortedGameServers returns the list of Game Servers for the Fleet in the order in which the
 // Game Servers would be deleted.
 func getSortedGameServers(f *agonesv1.Fleet, gameServerLister listeragonesv1.GameServerLister,
diff --git a/pkg/fleetautoscalers/fleetautoscalers_test.go b/pkg/fleetautoscalers/fleetautoscalers_test.go
index 0b1f9bee74..2104d2fc27 100644
--- a/pkg/fleetautoscalers/fleetautoscalers_test.go
+++ b/pkg/fleetautoscalers/fleetautoscalers_test.go
@@ -23,6 +23,7 @@ import (
 	"net/http"
 	"net/http/httptest"
 	"testing"
+	"time"
 
 	"github.com/stretchr/testify/assert"
 	admregv1 "k8s.io/api/admissionregistration/v1"
@@ -40,6 +41,7 @@ import (
 
 const (
 	scaleFactor = 2
+	webhookURL  = "scale"
 )
 
 type testServer struct{}
@@ -187,7 +189,7 @@ func TestComputeDesiredFleetSize(t *testing.T) {
 			_, cancel := agtesting.StartInformers(m, gameServers.Informer().HasSynced)
 			defer cancel()
 
-			replicas, limited, err := computeDesiredFleetSize(fas, f, gameServers.Lister(), nc)
+			replicas, limited, err := computeDesiredFleetSize(fas.Spec.Policy, f, gameServers.Lister(), nc)
 
 			if tc.expected.err != "" && assert.NotNil(t, err) {
 				assert.Equal(t, tc.expected.err, err.Error())
@@ -350,7 +352,7 @@ func TestApplyWebhookPolicy(t *testing.T) {
 	defer server.Close()
 
 	_, f := defaultWebhookFixtures()
-	url := "scale"
+	url := webhookURL
 	emptyString := ""
 	invalidURL := ")1golang.org/"
 	wrongServerURL := "http://127.0.0.1:1"
@@ -580,7 +582,7 @@ func TestApplyWebhookPolicy(t *testing.T) {
 func TestApplyWebhookPolicyNilFleet(t *testing.T) {
 	t.Parallel()
 
-	url := "scale"
+	url := webhookURL
 	w := &autoscalingv1.WebhookPolicy{
 		Service: &admregv1.ServiceReference{
 			Name:      "service1",
@@ -2134,3 +2136,413 @@ func TestApplyListPolicy(t *testing.T) {
 		})
 	}
 }
+
+// nolint:dupl  // Linter errors on lines are duplicate of TestApplySchedulePolicy
+// NOTE: Does not test for the validity of a fleet autoscaler policy (ValidateSchedulePolicy)
+func TestApplySchedulePolicy(t *testing.T) {
+	t.Parallel()
+
+	type expected struct {
+		replicas int32
+		limited  bool
+		wantErr  bool
+	}
+
+	bufferPolicy := autoscalingv1.FleetAutoscalerPolicy{
+		Type: autoscalingv1.BufferPolicyType,
+		Buffer: &autoscalingv1.BufferPolicy{
+			BufferSize:  intstr.FromInt(1),
+			MinReplicas: 3,
+			MaxReplicas: 10,
+		},
+	}
+	expectedWhenActive := expected{
+		replicas: 3,
+		limited:  false,
+		wantErr:  false,
+	}
+	expectedWhenInactive := expected{
+		replicas: 0,
+		limited:  false,
+		wantErr:  true,
+	}
+
+	testCases := map[string]struct {
+		featureFlags            string
+		specReplicas            int32
+		statusReplicas          int32
+		statusAllocatedReplicas int32
+		statusReadyReplicas     int32
+		now                     time.Time
+		sp                      *autoscalingv1.SchedulePolicy
+		gsList                  []agonesv1.GameServer
+		want                    expected
+	}{
+		"scheduled autoscaler feature flag not enabled": {
+			featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=false",
+			sp:           &autoscalingv1.SchedulePolicy{},
+			want: expected{
+				replicas: 0,
+				limited:  false,
+				wantErr:  true,
+			},
+		},
+		"no start time": {
+			featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true",
+			now:          mustParseTime("2020-12-26T08:30:00Z"),
+			sp: &autoscalingv1.SchedulePolicy{
+				Between: autoscalingv1.Between{
+					End: mustParseMetav1Time("2021-01-01T00:00:00Z"),
+				},
+				ActivePeriod: autoscalingv1.ActivePeriod{
+					Timezone:  "UTC",
+					StartCron: "* * * * *",
+					Duration:  "48h",
+				},
+				Policy: bufferPolicy,
+			},
+			want: expectedWhenActive,
+		},
+		"no end time": {
+			featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true",
+			now:          mustParseTime("2021-01-02T00:00:00Z"),
+			sp: &autoscalingv1.SchedulePolicy{
+				Between: autoscalingv1.Between{
+					Start: mustParseMetav1Time("2021-01-01T00:00:00Z"),
+				},
+				ActivePeriod: autoscalingv1.ActivePeriod{
+					Timezone:  "UTC",
+					StartCron: "* * * * *",
+					Duration:  "1h",
+				},
+				Policy: bufferPolicy,
+			},
+			want: expectedWhenActive,
+		},
+		"no cron time": {
+			featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true",
+			now:          mustParseTime("2021-01-01T0:30:00Z"),
+			sp: &autoscalingv1.SchedulePolicy{
+				Between: autoscalingv1.Between{
+					Start: mustParseMetav1Time("2021-01-01T00:00:00Z"),
+					End:   mustParseMetav1Time("2021-01-01T01:00:00Z"),
+				},
+				ActivePeriod: autoscalingv1.ActivePeriod{
+					Timezone: "UTC",
+					Duration: "1h",
+				},
+				Policy: bufferPolicy,
+			},
+			want: expectedWhenActive,
+		},
+		"no duration": {
+			featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true",
+			now:          mustParseTime("2021-01-01T0:30:00Z"),
+			sp: &autoscalingv1.SchedulePolicy{
+				Between: autoscalingv1.Between{
+					Start: mustParseMetav1Time("2021-01-01T00:00:00Z"),
+					End:   mustParseMetav1Time("2021-01-01T01:00:00Z"),
+				},
+				ActivePeriod: autoscalingv1.ActivePeriod{
+					Timezone:  "UTC",
+					StartCron: "* * * * *",
+				},
+				Policy: bufferPolicy,
+			},
+			want: expectedWhenActive,
+		},
+		"no start time, end time, cron time, duration": {
+			featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true",
+			now:          mustParseTime("2021-01-01T00:00:00Z"),
+			sp: &autoscalingv1.SchedulePolicy{
+				Policy: bufferPolicy,
+			},
+			want: expectedWhenActive,
+		},
+		"daylight saving time start": {
+			featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true",
+			now:          mustParseTime("2021-03-14T02:00:00Z"),
+			sp: &autoscalingv1.SchedulePolicy{
+				Between: autoscalingv1.Between{
+					Start: mustParseMetav1Time("2021-03-13T00:00:00Z"),
+					End:   mustParseMetav1Time("2021-03-15T00:00:00Z"),
+				},
+				ActivePeriod: autoscalingv1.ActivePeriod{
+					Timezone:  "UTC",
+					StartCron: "* 2 * * *",
+					Duration:  "1h",
+				},
+				Policy: bufferPolicy,
+			},
+			want: expectedWhenActive,
+		},
+		"daylight saving time end": {
+			featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true",
+			now:          mustParseTime("2021-11-07T01:59:59Z"),
+			sp: &autoscalingv1.SchedulePolicy{
+				Between: autoscalingv1.Between{
+					Start: mustParseMetav1Time("2021-11-07T00:00:00Z"),
+					End:   mustParseMetav1Time("2021-11-08T00:00:00Z"),
+				},
+				ActivePeriod: autoscalingv1.ActivePeriod{
+					Timezone:  "UTC",
+					StartCron: "0 2 * * *",
+					Duration:  "1h",
+				},
+				Policy: bufferPolicy,
+			},
+			want: expectedWhenActive,
+		},
+		"new year": {
+			featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true",
+			now:          mustParseTime("2021-01-01T00:00:00Z"),
+			sp: &autoscalingv1.SchedulePolicy{
+				Between: autoscalingv1.Between{
+					Start: mustParseMetav1Time("2020-12-31T24:59:59Z"),
+					End:   mustParseMetav1Time("2021-01-02T00:00:00Z"),
+				},
+				ActivePeriod: autoscalingv1.ActivePeriod{
+					Timezone:  "UTC",
+					StartCron: "* 0 * * *",
+					Duration:  "1h",
+				},
+				Policy: bufferPolicy,
+			},
+			want: expectedWhenActive,
+		},
+		"inactive schedule": {
+			featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true",
+			now:          mustParseTime("2023-12-12T03:49:00Z"),
+			sp: &autoscalingv1.SchedulePolicy{
+				Between: autoscalingv1.Between{
+					Start: mustParseMetav1Time("2022-12-31T24:59:59Z"),
+					End:   mustParseMetav1Time("2023-03-02T00:00:00Z"),
+				},
+				ActivePeriod: autoscalingv1.ActivePeriod{
+					Timezone:  "UTC",
+					StartCron: "* 0 * 3 *",
+					Duration:  "",
+				},
+				Policy: bufferPolicy,
+			},
+			want: expectedWhenInactive,
+		},
+	}
+
+	utilruntime.FeatureTestMutex.Lock()
+	defer utilruntime.FeatureTestMutex.Unlock()
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			err := utilruntime.ParseFeatures(tc.featureFlags)
+			assert.NoError(t, err)
+
+			_, f := defaultFixtures()
+			replicas, limited, err := applySchedulePolicy(tc.sp, f, nil, nil, tc.now)
+
+			if tc.want.wantErr {
+				assert.NotNil(t, err)
+			} else {
+				assert.Nil(t, err)
+				assert.Equal(t, tc.want.replicas, replicas)
+				assert.Equal(t, tc.want.limited, limited)
+			}
+		})
+	}
+}
+
+// nolint:dupl  // Linter errors on lines are duplicate of TestApplyChainPolicy
+// NOTE: Does not test for the validity of a fleet autoscaler policy (ValidateChainPolicy)
+func TestApplyChainPolicy(t *testing.T) {
+	t.Parallel()
+
+	// For Webhook Policy
+	ts := testServer{}
+	server := httptest.NewServer(ts)
+	defer server.Close()
+	url := webhookURL
+
+	type expected struct {
+		replicas int32
+		limited  bool
+		wantErr  bool
+	}
+
+	scheduleOne := autoscalingv1.ChainEntry{
+		ID: "schedule-1",
+		FleetAutoscalerPolicy: autoscalingv1.FleetAutoscalerPolicy{
+			Type: autoscalingv1.SchedulePolicyType,
+			Schedule: &autoscalingv1.SchedulePolicy{
+				Between: autoscalingv1.Between{
+					Start: mustParseMetav1Time("2024-08-01T10:07:36-06:00"),
+				},
+				ActivePeriod: autoscalingv1.ActivePeriod{
+					Timezone:  "America/Chicago",
+					StartCron: "* * * * *",
+					Duration:  "",
+				},
+				Policy: autoscalingv1.FleetAutoscalerPolicy{
+					Type: autoscalingv1.BufferPolicyType,
+					Buffer: &autoscalingv1.BufferPolicy{
+						BufferSize:  intstr.FromInt(1),
+						MinReplicas: 10,
+						MaxReplicas: 10,
+					},
+				},
+			},
+		},
+	}
+	scheduleTwo := autoscalingv1.ChainEntry{
+		ID: "schedule-2",
+		FleetAutoscalerPolicy: autoscalingv1.FleetAutoscalerPolicy{
+			Type: autoscalingv1.SchedulePolicyType,
+			Schedule: &autoscalingv1.SchedulePolicy{
+				Between: autoscalingv1.Between{
+					End: mustParseMetav1Time("2021-01-02T4:53:00-05:00"),
+				},
+				ActivePeriod: autoscalingv1.ActivePeriod{
+					Timezone:  "America/New_York",
+					StartCron: "0 1 3 * *",
+					Duration:  "",
+				},
+				Policy: autoscalingv1.FleetAutoscalerPolicy{
+					Type: autoscalingv1.BufferPolicyType,
+					Buffer: &autoscalingv1.BufferPolicy{
+						BufferSize:  intstr.FromInt(1),
+						MinReplicas: 3,
+						MaxReplicas: 10,
+					},
+				},
+			},
+		},
+	}
+	webhookEntry := autoscalingv1.ChainEntry{
+		ID: "webhook policy",
+		FleetAutoscalerPolicy: autoscalingv1.FleetAutoscalerPolicy{
+			Type: autoscalingv1.WebhookPolicyType,
+			Webhook: &autoscalingv1.WebhookPolicy{
+				Service: &admregv1.ServiceReference{
+					Name:      "service1",
+					Namespace: "default",
+					Path:      &url,
+				},
+				CABundle: []byte("invalid-value"),
+			},
+		},
+	}
+	defaultEntry := autoscalingv1.ChainEntry{
+		ID: "default",
+		FleetAutoscalerPolicy: autoscalingv1.FleetAutoscalerPolicy{
+			Type: autoscalingv1.BufferPolicyType,
+			Buffer: &autoscalingv1.BufferPolicy{
+				BufferSize:  intstr.FromInt(1),
+				MinReplicas: 6,
+				MaxReplicas: 10,
+			},
+		},
+	}
+
+	testCases := map[string]struct {
+		fleet                   *agonesv1.Fleet
+		featureFlags            string
+		specReplicas            int32
+		statusReplicas          int32
+		statusAllocatedReplicas int32
+		statusReadyReplicas     int32
+		now                     time.Time
+		cp                      *autoscalingv1.ChainPolicy
+		gsList                  []agonesv1.GameServer
+		want                    expected
+	}{
+		"scheduled autoscaler feature flag not enabled": {
+			featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=false",
+			cp:           &autoscalingv1.ChainPolicy{},
+			want: expected{
+				replicas: 0,
+				limited:  false,
+				wantErr:  true,
+			},
+		},
+		"default policy": {
+			featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true",
+			cp:           &autoscalingv1.ChainPolicy{defaultEntry},
+			want: expected{
+				replicas: 6,
+				limited:  true,
+				wantErr:  false,
+			},
+		},
+		"one invalid webhook policy, one default (fallthrough)": {
+			featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true",
+			cp:           &autoscalingv1.ChainPolicy{webhookEntry, defaultEntry},
+			want: expected{
+				replicas: 6,
+				limited:  true,
+				wantErr:  false,
+			},
+		},
+		"two inactive schedule entries, no default (fall off chain)": {
+			featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true",
+			now:          mustParseTime("2021-01-01T0:30:00Z"),
+			cp:           &autoscalingv1.ChainPolicy{scheduleOne, scheduleOne},
+			want: expected{
+				replicas: 5,
+				limited:  false,
+				wantErr:  true,
+			},
+		},
+		"two inactive schedules entries, one default (fallthrough)": {
+			featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true",
+			now:          mustParseTime("2021-11-05T5:30:00Z"),
+			cp:           &autoscalingv1.ChainPolicy{scheduleOne, scheduleTwo, defaultEntry},
+			want: expected{
+				replicas: 6,
+				limited:  true,
+				wantErr:  false,
+			},
+		},
+		"two overlapping/active schedule entries, schedule-1 applied": {
+			featureFlags: string(utilruntime.FeatureScheduledAutoscaler) + "=true",
+			now:          mustParseTime("2024-08-01T10:07:36-06:00"),
+			cp:           &autoscalingv1.ChainPolicy{scheduleOne, scheduleTwo},
+			want: expected{
+				replicas: 10,
+				limited:  true,
+				wantErr:  false,
+			},
+		},
+	}
+
+	utilruntime.FeatureTestMutex.Lock()
+	defer utilruntime.FeatureTestMutex.Unlock()
+
+	for name, tc := range testCases {
+		t.Run(name, func(t *testing.T) {
+			err := utilruntime.ParseFeatures(tc.featureFlags)
+			assert.NoError(t, err)
+
+			_, f := defaultFixtures()
+			replicas, limited, err := applyChainPolicy(*tc.cp, f, nil, nil, tc.now)
+
+			if tc.want.wantErr {
+				assert.NotNil(t, err)
+			} else {
+				assert.Nil(t, err)
+				assert.Equal(t, tc.want.replicas, replicas)
+				assert.Equal(t, tc.want.limited, limited)
+			}
+		})
+	}
+}
+
+// Parse a time string and return a metav1.Time
+func mustParseMetav1Time(timeStr string) metav1.Time {
+	t, _ := time.Parse(time.RFC3339, timeStr)
+	return metav1.Date(t.Year(), t.Month(), t.Day(), t.Hour(), t.Minute(), t.Second(), t.Nanosecond(), t.Location())
+}
+
+// Parse a time string and return a time.Time
+func mustParseTime(timeStr string) time.Time {
+	t, _ := time.Parse(time.RFC3339, timeStr)
+	return t
+}