Xbox Cloud Gaming Free: Hours Tracker in 13 Steps [2026]

Xbox Cloud Gaming used to be a perk you stopped thinking about. This November it turns into a meter. Xbox announced on September 3, 2026 that monthly cloud-hour limits would start in November, and Make Tech Easier’s breakdown of the caps lists 15 hours a month on Ultimate, 10 on Premium and 5 on Essential. Xbox’s own materials do not say whether every market flipped the switch on exactly November 2, so you cannot assume the cap is live on your account today.

At the same time, Xbox cracked open a door for people who pay nothing. On October 21, 2026 it began letting Xbox Insiders test xbox cloud gaming free with ads, no Game Pass subscription required, plus paid “cloud playtime” packs for anyone who wants an ad-free stream. Free and metered now live side by side, and it is easy to lose track of how many minutes you burn in a month.

This tutorial fixes that. You will build cloudhours, a small Python command-line tracker that logs every session, measures how long you wait before the stream starts, compares your minutes with your plan allowance, and tracks free ad sessions and pack balances separately. It takes about 45 minutes, uses only the Python standard library, and ends with a tested project you can copy whole. If you are not yet in the preview, our walkthrough of the Xbox Insider Program cloud test covers how people get in.

Google · Preferred Sources

Don’t miss new tech stories on Google

Add Tech Insider once in the Google app and our stories appear in your news suggestions.

Add Now

What Changed in Xbox Cloud Gaming This November

Until now, eligible Game Pass subscribers streamed without a monthly limit. The September 3 announcement replaced that arrangement with fixed monthly hour allowances, scheduled to begin in November. When you burn through the included hours, you can buy more time through the Xbox Store. Microsoft has also described a second track for people who skip subscriptions entirely, where you buy cloud playtime and stream eligible games you own.

The allowances by plan are the numbers that matter for a tracker. Essential gets 5 hours, Premium gets 10 and Ultimate gets 15. The table converts them into minutes and a daily budget over a 30-day period so the numbers feel concrete.

Game Pass plan Monthly cloud hours Minutes Daily budget over 30 days cloudhours flag
Essential 5 300 10 min --plan essential
Premium 10 600 20 min --plan premium
Ultimate 15 900 30 min --plan ultimate
PC Game Pass Xbox pages conflict, check your account n/a n/a --plan custom --allowance N

That last row deserves a flag. In the pages I reviewed, one Xbox listing showed 15 cloud hours for PC Game Pass while another said cloud gaming is not included in that plan. Rather than guess, the tracker lets you type the allowance your own account page shows. If you hold a different plan, the same --allowance override applies.

Two budget numbers jump out. A Premium subscriber gets roughly 20 minutes a day, which is less than one episode of a streamed show and far less than a normal evening of play. An Ultimate subscriber gets 30. Anyone who games on a handheld during a commute or on a TV without a console will run into these ceilings fast. Thurrott’s report on Insiders paying for cloud playtime without Game Pass shows how quickly Microsoft is building the pay-as-you-go layer on top.

How Xbox Cloud Gaming Free With Ads Actually Works

The free route is a preview, and the details are thin. Per Xbox’s October 21 announcement, Insiders without an active Game Pass subscription can test cloud gaming for free with ads. Xbox’s cloud playtime support page describes the loop in three short sentences. “You can stream select games for free by watching ads before each gaming session.” Then: “Session time limits apply.” And finally: “After a session ends, you can watch another set of ads to start a new session.”

Notice what is missing. Xbox publishes no number for the session limit in the material I reviewed, and it names no monthly cap for the free route. The October 21 post was updated on October 31 and still does not give an end date for the preview. So the honest answer to “how long can I play for free” is that you have to measure it. The tracker’s report command does exactly that by showing the longest ad-supported session you have logged.

Paid packs sit at the other end of the spectrum. Xbox’s announcement says “Cloud playtime packs provide an ad-free cloud gaming experience, including shorter wait times, high-quality streaming, and playtime that remains available until it’s used.” The introductory preview prices were 10 hours for $3.99, 25 hours for $7.99 and 50 hours for $11.99. Microsoft reportedly planned a broader rollout for November 2026, and it has not confirmed final regional pricing, so treat those numbers as preview prices.

Access route What you pay Status on November 2, 2026 cloudhours mode
Game Pass plan allowance Monthly subscription with 5, 10 or 15 cloud hours Monthly limits announced September 3 for November plan
Free with ads $0, ads before each session, session time limits apply Insider preview since October 21 ads
Cloud playtime packs 10 h for $3.99, 25 h for $7.99, 50 h for $11.99 (introductory) Insider preview, broader rollout planned for November pack

If you want the one-game route instead of a full tracker, our guide to playing Fortnite through Xbox Cloud Gaming covers it, and the separate piece on streaming without a Game Pass subscription explains the account side. This article is about the numbers.

Prerequisites and Versions

You need very little. The tracker uses Python’s built-in sqlite3, argparse, csv and datetime modules, so there is nothing to install from a package index. The table lists what I used and what the project needs.

Requirement Version or detail Why you need it
Python 3.11 or newer (tested on 3.12.3) Runs the tracker and the tests
SQLite Bundled with Python’s sqlite3 module Stores sessions and pack purchases
Terminal Any shell on Windows, macOS or Linux Runs the commands
Browser A current browser that can open xbox.com/play The stream you are timing
Microsoft account Signed in at Xbox Plan details and the Store
Xbox Insider enrollment Optional Only needed for the free ad-supported and pack tests

Xbox says cloud gaming runs through supported devices, Xbox consoles, the Xbox app and xbox.com/play, with availability varying by region, device and account. Mac players should check our Xbox Cloud Gaming on Mac setup first, because the tracker is only useful once the stream itself works. For wider context on where this fits, browse our gaming coverage.

You also need a rough habit: run one command when you press Play, one when the picture appears, and one when you quit. That is three keystrokes of overhead per session, and it is the whole cost of owning real data.

Phase 1: Set Up the Project (Steps 1-3)

Step 1: Confirm Your Plan and Reset Date

Open your Microsoft account’s subscription page and write down three things: the exact plan name, the cloud hours it lists, and the date your billing or allowance period resets. Official names today are Xbox Game Pass Essential, Premium and Ultimate, plus PC Game Pass. Jot the numbers on paper. You will compare them with the tracker in Step 13.

The reset day matters more than people expect. Xbox’s announcement does not spell out whether the allowance resets on the first of the month or on your billing date, and the material I reviewed does not settle it. The tracker takes a --reset-day between 1 and 28 so you can match whatever your account shows.

Step 2: Create the Project Folder and Check Python

Make a folder and confirm your Python version. The code uses f-strings and datetime.fromisoformat, which behave the way this tutorial expects on Python 3.11 and newer.

mkdir cloudhours
cd cloudhours
python3 --version

On Windows, use py -3 --version if python3 is not found. Expected output on my test machine:

Python 3.12.3

Save the full cloudhours.py from the project section near the end of this article into this folder, along with test_cloudhours.py. The next steps explain the pieces so you know what each command does before you run it.

Step 3: Initialise the Config

The init command writes a small JSON config and creates the SQLite database. Pick your plan, your reset day and a warning threshold. The default warning fires at 80 percent of the plan allowance.

python3 cloudhours.py init --plan premium --reset-day 1
Saved /home/you/.cloudhours/config.json
Plan premium: 10 h per period, resets on day 1

Everything lives in ~/.cloudhours unless you set the CLOUDHOURS_HOME environment variable. That variable is how the tests run against a throwaway folder, and it is how a household can keep one tracker per person.

For a custom allowance, such as PC Game Pass or a promotional bundle, use --plan custom --allowance 12 and the tracker uses 12 hours.

Phase 2: Log Every Session (Steps 4-6)

Step 4: Log Your First Session by Hand

Before timing live sessions, practice with log, which records a finished session. It takes the game name, the mode, the minutes you streamed and an optional queue time. The game names below are placeholders.

python3 cloudhours.py log --game "Sample Game A" --minutes 95 --queue-min 4
Logged 1 h 35 min of plan time for Sample Game A.

Under the hood, the command works backward from the end time. It treats the session as ending now, subtracts the minutes to get the moment the stream went live, then subtracts the queue to get the moment you pressed Play. Those three timestamps land in one row, which is why log and the live timer produce identical data.

Here is the table that holds them:

CREATE TABLE IF NOT EXISTS sessions (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    game TEXT NOT NULL,
    mode TEXT NOT NULL CHECK (mode IN ('plan', 'ads', 'pack')),
    started_at TEXT NOT NULL,
    live_at TEXT,
    ended_at TEXT,
    minutes REAL
);

The CHECK constraint rejects any mode other than the three routes in the access table, which keeps typos out of your totals.

Step 5: Time a Live Session With start, live and stop

This is the loop you will actually use. Press Play in the browser and run start in the same moment. When the stream appears, run live. When you quit, run stop.

python3 cloudhours.py start --game "Sample Game D" --mode plan
python3 cloudhours.py live
python3 cloudhours.py stop
Session 8 started (plan, Sample Game D). Run live when the stream appears.
Stream live after 150 s in the queue or ad break.
Session 8 closed: 0 h 50 min counted (plan).

The split between start and live is the most useful design choice in the project. It separates waiting from playing. The wait covers queue time, and in the ad-supported route it also covers the ad break. Minutes are counted from the live moment, because that is when you are actually streaming. If your account’s counter disagrees, Step 13 shows how to flip that rule.

That split also lets you test Xbox’s own claim. The pack announcement promises shorter wait times. After a few weeks of data, the report command shows the average wait for each mode side by side, and you will know whether paying for a pack buys you a faster start on your connection. Our free cloud gaming lag test pairs well here, since queue time and in-game latency are different problems.

Step 6: Backfill Sessions You Forgot to Time

You will forget. Everyone does. Use log with --ended to backfill a session after the fact, giving the ISO time it finished:

python3 cloudhours.py log --game "Sample Game B" --minutes 80 --queue-min 3 --ended 2026-11-08T20:45

A naive timestamp like that one is read as local time. The tracker stores every timestamp with its UTC offset, so exports stay unambiguous even if you travel. Backfilled sessions only count toward the period that contains their end time, which is the behavior you want when you log a session from last month.

One guard protects you from the opposite mistake. If you run start while a session is still open, the tracker refuses and tells you to run stop first. That rule prevents two overlapping timers from double-counting the same evening.

Phase 3: Read the Budget (Steps 7-9)

Step 7: Read the status Output

The status command is the one you will run most. With sample data, it prints this:

Period      : 2026-11-01 to 2026-12-01 (day 12 of 30)
Plan        : premium, 10 h (600 min)
Plan used   : 4 h 55 min (49.2%), left 5 h 05 min
Pace        : on track for 12 h 23 min this period
Free (ads)  : 3 sessions, 1 h 44 min
Pack balance: 34 h 00 min
HEADS UP    : current pace would pass the allowance before the reset.

Read it line by line. The period line shows which window the tracker is counting, so you can spot a wrong reset day immediately. The plan lines compare plan-mode minutes with the allowance. The pace line extrapolates linearly to the end of the period. The free line counts ad sessions on their own, because Xbox describes that route as a separate option. The pack balance is the hours you bought minus the pack minutes you used.

The final line is the warning logic. It checks three conditions in order. Over the allowance prints OVER. At or past your warning threshold prints WARNING. A projected overrun prints HEADS UP. Only the first matching message appears, so the output stays short.

Step 8: Understand the Period Math

The trickiest function in the project decides which sessions belong to the current period. It takes a reference time and a reset day, then walks back one month if today’s day number is before the reset day.

def period_bounds(ref, reset_day):
    local = ref.astimezone()
    year, month = local.year, local.month
    if local.day < reset_day:
        month -= 1
        if month == 0:
            year, month = year - 1, 12
    start = datetime(year, month, reset_day).astimezone()
    next_year, next_month = (year + 1, 1) if month == 12 else (year, month + 1)
    end = datetime(next_year, next_month, reset_day).astimezone()
    return start, end

The reset day is capped at 28 so every month has one, which avoids the February problem entirely. The January branch handles the year rollover, and a test covers it. Calling .astimezone() on a naive datetime attaches the machine’s local offset for that specific date, so daylight saving changes do not shift your boundaries by a day.

Step 9: Tune the Warning and Read the Pace

Raise or lower the warning threshold by running init again with --warn-pct. Re-running init rewrites the config but keeps your history, because the database is untouched. Dropping the threshold to 40 percent on the sample data gives:

WARNING     : 57% of the plan allowance is gone.

After a long extra session, the same data crosses the allowance and the tracker switches to the stronger message:

OVER        : plan minutes exceed the allowance. Log extra time with --mode pack.

The pace projection stays quiet during the first 10 percent of a period. Extrapolating from one evening of play would produce silly numbers, so the code returns “too early to project” until enough of the period has passed. If you play in bursts, treat the pace line as a rough guide and trust the used-minutes line instead.

Phase 4: Track Free Ad Sessions and Packs (Steps 10-11)

Step 10: Log Free Sessions and Find Your Real Session Cap

Every time you stream through the free ad-supported route, log it with --mode ads. These sessions never touch your plan minutes, which matches how Xbox describes the route as separate from subscription limits.

python3 cloudhours.py start --game "Sample Game B" --mode ads

After a dozen sessions, run the report:

python3 cloudhours.py report
mode   sessions   hours  avg min  longest  avg queue
plan          3     4.9       98      120    4.3 min
ads           3     1.7       35       35    2.3 min
pack          1     1.0       60       60    1.0 min

These numbers are sample data I invented to exercise the code, not Xbox’s limits. In real use, the longest column in the ads row is your best estimate of the session cap, and a tight cluster around one value (such as three sessions all ending near the same minute) is a strong hint that a timer, not you, ended them. Xbox says session time limits apply but publishes no figure in the material I reviewed, so your own log is the best available source.

The avg queue column answers a second question. If ad sessions wait much longer than plan sessions, the free route costs you time as well as ad views. Gamers who struggle with lag spikes on top of waiting should also read our bufferbloat fix for free cloud gaming.

Step 11: Record Pack Purchases and Cost Per Hour

When you buy a cloud playtime pack, record it right away:

python3 cloudhours.py pack-add --hours 10 --price 3.99
python3 cloudhours.py pack-add --hours 25 --price 7.99
Pack saved: 10 h for $3.99 ($0.40 per hour).
Pack saved: 25 h for $7.99 ($0.32 per hour).

The per-hour figures are simple division: 3.99 divided by 10 is about 40 cents, and 7.99 divided by 25 is about 32 cents. The 50-hour pack at $11.99 works out to about 24 cents. Bigger packs cost less per hour, so the break-even question for a heavy player is whether a pack beats the extra hours Xbox sells after a plan runs dry.

Pack hours behave differently from plan hours. Xbox says pack playtime remains available until it is used, so the tracker never resets the pack balance at the period boundary. The balance is every hour you ever bought minus every pack minute you ever logged. A unit test covers exactly that: a pack bought in October still counts in November.

Note one assumption. The tracker does not know in what order Xbox draws down plan hours and pack hours, so you tell it by choosing --mode at the start of each session. Log what you believe Xbox charged, then check Step 13.

Phase 5: Test and Reconcile (Steps 12-13)

Step 12: Run the Unit Tests

The project ships seven tests that cover the period boundaries, the January rollover, plan totals, ad isolation, pack balances across a reset, and exclusion of last period’s sessions. They set a fixed clock through the CLOUDHOURS_NOW variable so they never depend on today’s date.

python3 -m unittest
Ran 7 tests in 0.069s
OK

Run the command from the project folder. The tests import cloudhours directly, so a different working directory raises ModuleNotFoundError. When you change anything about the period math, add a test first. Date logic is the part of this project most likely to hide a bug until the first of the month.

Step 13: Reconcile Against Xbox and Export

A tracker is only as good as its agreement with the source of truth. Once a week, compare status with whatever remaining-time figure your Xbox account or app shows. If your account shows one, the two should agree within a few minutes. If they diverge by roughly the amount of your total queue time, Xbox is probably counting from the moment you press Play instead of the moment the stream appears.

To make the tracker match that rule, change one line in cmd_stop. Replace begin = datetime.fromisoformat(row["live_at"] or row["started_at"]) with begin = datetime.fromisoformat(row["started_at"]). Every future session then counts queue time as billed time.

Finally, export your history so you own it outside the database:

python3 cloudhours.py export sessions.csv
Wrote sessions.csv
id,game,mode,started_at,live_at,ended_at,minutes
1,Sample Game A,plan,2026-11-03T19:51:00+00:00,2026-11-03T19:55:00+00:00,2026-11-03T21:30:00+00:00,95.0

Open the CSV in any spreadsheet and chart minutes per week. A single glance usually tells you whether Premium or Ultimate fits how you play.

Command Cheat Sheet

Command What it does Example
init Create config and database init --plan ultimate --reset-day 15
start Open a session when you press Play start --game "Name" --mode ads
live Mark the moment the stream appears live
stop Close the session and print status stop
log Add a finished session by hand log --game "Name" --minutes 45 --queue-min 2
status Show allowance, pace and balances status
pack-add Record a pack purchase pack-add --hours 25 --price 7.99
report Per-mode averages, longest, queue report --all
list Show recent sessions list --limit 5
export Write sessions to CSV export sessions.csv

Common Pitfalls

Seven mistakes account for nearly every wrong total I saw while testing the tracker.

  • Assuming the cap is already live. Xbox said the limits begin in November, and its materials do not confirm every market switched on November 2. Track from day one anyway, but do not panic if your account shows no counter yet.
  • Mixing up the three modes. Logging a free ad session as plan quietly eats your allowance on paper. Pick the mode at start and double-check it.
  • Forgetting to run stop. An open session keeps accumulating wall-clock time. Run status before you close the laptop, and fix stray rows with the recipe in the troubleshooting table.
  • Counting queue time as play time. The tracker counts from the live moment by default. If Xbox counts from Play, your totals will run low, so verify against your account in Step 13.
  • Trusting the pace line early in the period. A single marathon session in the first three days projects a wildly high month. The tracker suppresses projections for the first 10 percent for that reason.
  • Setting the wrong reset day. If your totals look right on the last day of a month and wrong on the first, the reset day is off by a few days. Compare the period line in status with your account.
  • Treating preview prices as final. The pack prices of $3.99, $7.99 and $11.99 were introductory preview prices, and Microsoft has not confirmed final regional pricing. Update your pack-add entries if the real charge differs.

Troubleshooting: 10 Fixes for Common Errors

Symptom Likely cause Fix
No config yet. Run: cloudhours.py init --plan premium You skipped init, or CLOUDHOURS_HOME points elsewhere Run init, then run echo $CLOUDHOURS_HOME to confirm the folder
A session is already open. Run stop first. A previous session never closed Run stop, or delete the stray row with a one-line Python command
stop reports many hours You forgot to stop a session days ago Delete the open row, then re-enter the real time with log
ModuleNotFoundError: No module named 'cloudhours' You ran the tests from another folder cd into the project folder, then run python3 -m unittest
reset day must be between 1 and 28 The argument is out of range Use a day from 1 to 28, since the cap sidesteps short months
Plan used stays at 0 after logging Wrong mode, or the end date falls outside the period Run list and check the mode and date columns
Pack balance goes negative You logged pack minutes before pack-add Add the purchase with --when set to the real purchase time
Pace says “too early to project” Under 10 percent of the period has passed Wait a few days, since this is deliberate
Times look an hour off after a clock change A naive timestamp was read in the wrong season Pass --ended with a full offset, such as 2026-11-08T20:45-05:00
python3 not found on Windows Windows uses the py launcher Run py -3 cloudhours.py status and set variables with $env:CLOUDHOURS_HOME in PowerShell

The stray-row fix from the second row is a single command, shown here for completeness:

python3 -c "import sqlite3, pathlib; c = sqlite3.connect(pathlib.Path.home() / '.cloudhours' / 'cloudhours.db'); c.execute('DELETE FROM sessions WHERE ended_at IS NULL'); c.commit()"

If the stream itself misbehaves rather than the tracker, our guide to fixing Xbox Cloud Gaming when it will not work covers the common failures. Lost progress is a separate topic, handled in the free cloud gaming save sync audit.

Advanced Tips

Once the basics run, a few small changes pay off.

Add a shell shortcut. A one-line function turns three commands into one habit. Put this in your bash or zsh profile:

cg() { python3 ~/cloudhours/cloudhours.py "$@"; }

Then you can type cg start --game X, cg live and cg stop.

Count queue as billed time. The one-line change from Step 13 is worth keeping in a branch. Run both versions for a fortnight against your Xbox counter and keep whichever matches.

Keep one database per person. Set CLOUDHOURS_HOME per profile, such as ~/.cloudhours-kid, and each household member gets an isolated config, plan and pack balance. Parents who want to know why the Premium hours vanished by the 12th will find this useful.

Chart the export. Load sessions.csv into a spreadsheet and pivot minutes by week and by mode. A weekly bar chart shows quickly whether you spend most hours on plan, ads or packs.

Price your hours. Divide any plan price you pay by the hours you actually use. If the reported $14.99 monthly price for Premium is right, ten hours would cost about $1.50 an hour when fully used, but the plan also includes a game library, so a per-hour comparison with packs is unfair. Use it only to see whether you are paying for hours you never touch.

Use it as a test bench. Because live timestamps the wait, you can compare time-of-day patterns. Evening queues may run longer than mornings. Pair the data with a gamepad tester for cloud gaming to rule out controller faults when a session feels wrong.

Schedule a reminder. Add a weekly calendar event or a cron job that runs status and mails you the output. A plan that gets checked weekly rarely surprises you on the 28th.

The Complete Working Project

The listing below is the whole tracker. Save it as cloudhours.py, then save the tests beneath it as test_cloudhours.py in the same folder. Total size is a few hundred lines, with no third-party dependencies.

#!/usr/bin/env python3
"""cloudhours: track Xbox Cloud Gaming time against a monthly allowance."""
import argparse
import csv
import json
import os
import sqlite3
import sys
from datetime import datetime, timedelta, timezone
from pathlib import Path

PLAN_HOURS = {"essential": 5, "premium": 10, "ultimate": 15, "custom": 0}
MODES = ("plan", "ads", "pack")

SCHEMA = """
CREATE TABLE IF NOT EXISTS sessions (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    game TEXT NOT NULL,
    mode TEXT NOT NULL CHECK (mode IN ('plan', 'ads', 'pack')),
    started_at TEXT NOT NULL,
    live_at TEXT,
    ended_at TEXT,
    minutes REAL
);
CREATE TABLE IF NOT EXISTS packs (
    id INTEGER PRIMARY KEY AUTOINCREMENT,
    bought_at TEXT NOT NULL,
    hours REAL NOT NULL,
    price REAL NOT NULL
);
"""


def home():
    return Path(os.environ.get("CLOUDHOURS_HOME", Path.home() / ".cloudhours"))


def now():
    override = os.environ.get("CLOUDHOURS_NOW")
    if override:
        return datetime.fromisoformat(override).astimezone()
    return datetime.now(timezone.utc)


def connect():
    home().mkdir(parents=True, exist_ok=True)
    con = sqlite3.connect(home() / "cloudhours.db")
    con.row_factory = sqlite3.Row
    con.executescript(SCHEMA)
    return con


def load_config():
    path = home() / "config.json"
    if not path.exists():
        sys.exit("No config yet. Run: cloudhours.py init --plan premium")
    return json.loads(path.read_text())


def fmt_minutes(total):
    total = round(total)
    hours, minutes = divmod(abs(total), 60)
    sign = "-" if total < 0 else ""
    return f"{sign}{hours} h {minutes:02d} min"


def period_bounds(ref, reset_day):
    local = ref.astimezone()
    year, month = local.year, local.month
    if local.day < reset_day:
        month -= 1
        if month == 0:
            year, month = year - 1, 12
    start = datetime(year, month, reset_day).astimezone()
    next_year, next_month = (year + 1, 1) if month == 12 else (year, month + 1)
    end = datetime(next_year, next_month, reset_day).astimezone()
    return start, end


def done_sessions(con):
    rows = []
    for row in con.execute("SELECT * FROM sessions WHERE ended_at IS NOT NULL ORDER BY id"):
        item = dict(row)
        item["started"] = datetime.fromisoformat(row["started_at"])
        item["live"] = datetime.fromisoformat(row["live_at"]) if row["live_at"] else None
        item["ended"] = datetime.fromisoformat(row["ended_at"])
        item["queue_min"] = (
            (item["live"] - item["started"]).total_seconds() / 60 if item["live"] else None
        )
        rows.append(item)
    return rows


def open_session(con):
    return con.execute(
        "SELECT * FROM sessions WHERE ended_at IS NULL ORDER BY id DESC LIMIT 1"
    ).fetchone()


def pack_balance(con, sessions):
    bought = con.execute("SELECT COALESCE(SUM(hours), 0) FROM packs").fetchone()[0] * 60
    spent = sum(s["minutes"] for s in sessions if s["mode"] == "pack")
    return bought - spent


def build_status(con, cfg, ref=None):
    ref = ref or now()
    start, end = period_bounds(ref, cfg["reset_day"])
    every = done_sessions(con)
    inside = [s for s in every if start <= s["ended"] < end]
    allowance = cfg["allowance_hours"] * 60
    used = sum(s["minutes"] for s in inside if s["mode"] == "plan")
    ads = [s for s in inside if s["mode"] == "ads"]
    elapsed = (ref - start) / (end - start)
    projected = used / elapsed if elapsed >= 0.1 else None
    return {
        "start": start,
        "end": end,
        "day": (ref.astimezone().date() - start.date()).days + 1,
        "days": (end.date() - start.date()).days,
        "allowance": allowance,
        "used": used,
        "left": allowance - used,
        "pct": (used / allowance * 100) if allowance else 0.0,
        "projected": projected,
        "ads_sessions": len(ads),
        "ads_minutes": sum(s["minutes"] for s in ads),
        "pack_left": pack_balance(con, every),
    }


def print_status(cfg, st):
    print(f"Period      : {st['start']:%Y-%m-%d} to {st['end']:%Y-%m-%d} (day {st['day']} of {st['days']})")
    print(f"Plan        : {cfg['plan']}, {cfg['allowance_hours']} h ({int(st['allowance'])} min)")
    print(f"Plan used   : {fmt_minutes(st['used'])} ({st['pct']:.1f}%), left {fmt_minutes(st['left'])}")
    if st["projected"] is None:
        print("Pace        : too early in the period to project")
    else:
        print(f"Pace        : on track for {fmt_minutes(st['projected'])} this period")
    print(f"Free (ads)  : {st['ads_sessions']} sessions, {fmt_minutes(st['ads_minutes'])}")
    print(f"Pack balance: {fmt_minutes(st['pack_left'])}")
    if st["allowance"] and st["used"] > st["allowance"]:
        print("OVER        : plan minutes exceed the allowance. Log extra time with --mode pack.")
    elif st["allowance"] and st["pct"] >= cfg["warn_pct"]:
        print(f"WARNING     : {st['pct']:.0f}% of the plan allowance is gone.")
    elif st["projected"] and st["allowance"] and st["projected"] > st["allowance"]:
        print("HEADS UP    : current pace would pass the allowance before the reset.")


def reset_day_type(value):
    number = int(value)
    if not 1 <= number <= 28:
        raise argparse.ArgumentTypeError("reset day must be between 1 and 28")
    return number


def cmd_init(args):
    hours = args.allowance if args.allowance is not None else PLAN_HOURS[args.plan]
    cfg = {
        "plan": args.plan,
        "allowance_hours": hours,
        "reset_day": args.reset_day,
        "warn_pct": args.warn_pct,
    }
    home().mkdir(parents=True, exist_ok=True)
    (home() / "config.json").write_text(json.dumps(cfg, indent=2))
    connect().close()
    print(f"Saved {home() / 'config.json'}")
    print(f"Plan {args.plan}: {hours} h per period, resets on day {args.reset_day}")


def cmd_start(args):
    con = connect()
    if open_session(con):
        sys.exit("A session is already open. Run stop first.")
    cur = con.execute(
        "INSERT INTO sessions (game, mode, started_at) VALUES (?, ?, ?)",
        (args.game, args.mode, now().isoformat()),
    )
    con.commit()
    print(f"Session {cur.lastrowid} started ({args.mode}, {args.game}). Run live when the stream appears.")


def cmd_live(_args):
    con = connect()
    row = open_session(con)
    if not row:
        sys.exit("No open session. Run start first.")
    if row["live_at"]:
        sys.exit("This session is already live.")
    stamp = now()
    queue = (stamp - datetime.fromisoformat(row["started_at"])).total_seconds()
    con.execute("UPDATE sessions SET live_at = ? WHERE id = ?", (stamp.isoformat(), row["id"]))
    con.commit()
    print(f"Stream live after {queue:.0f} s in the queue or ad break.")


def cmd_stop(_args):
    con = connect()
    cfg = load_config()
    row = open_session(con)
    if not row:
        sys.exit("No open session. Run start first.")
    stamp = now()
    begin = datetime.fromisoformat(row["live_at"] or row["started_at"])
    minutes = max((stamp - begin).total_seconds() / 60, 0)
    con.execute(
        "UPDATE sessions SET ended_at = ?, minutes = ? WHERE id = ?",
        (stamp.isoformat(), minutes, row["id"]),
    )
    con.commit()
    print(f"Session {row['id']} closed: {fmt_minutes(minutes)} counted ({row['mode']}).")
    print_status(cfg, build_status(con, cfg))


def cmd_log(args):
    con = connect()
    ended = datetime.fromisoformat(args.ended).astimezone() if args.ended else now()
    live = ended - timedelta(minutes=args.minutes)
    started = live - timedelta(minutes=args.queue_min)
    con.execute(
        "INSERT INTO sessions (game, mode, started_at, live_at, ended_at, minutes) "
        "VALUES (?, ?, ?, ?, ?, ?)",
        (args.game, args.mode, started.isoformat(), live.isoformat(), ended.isoformat(), args.minutes),
    )
    con.commit()
    print(f"Logged {fmt_minutes(args.minutes)} of {args.mode} time for {args.game}.")


def cmd_status(_args):
    con = connect()
    cfg = load_config()
    print_status(cfg, build_status(con, cfg))


def cmd_pack_add(args):
    con = connect()
    when = datetime.fromisoformat(args.when).astimezone() if args.when else now()
    con.execute(
        "INSERT INTO packs (bought_at, hours, price) VALUES (?, ?, ?)",
        (when.isoformat(), args.hours, args.price),
    )
    con.commit()
    print(f"Pack saved: {args.hours:g} h for ${args.price:.2f} (${args.price / args.hours:.2f} per hour).")


def cmd_report(args):
    con = connect()
    cfg = load_config()
    every = done_sessions(con)
    if not args.all:
        start, end = period_bounds(now(), cfg["reset_day"])
        every = [s for s in every if start <= s["ended"] < end]
    print(f"{'mode':<6}{'sessions':>9}{'hours':>8}{'avg min':>9}{'longest':>9}{'avg queue':>11}")
    for mode in MODES:
        group = [s for s in every if s["mode"] == mode]
        if not group:
            print(f"{mode:<6}{0:>9}")
            continue
        minutes = [s["minutes"] for s in group]
        queues = [s["queue_min"] for s in group if s["queue_min"] is not None]
        avg_queue = f"{sum(queues) / len(queues):.1f} min" if queues else "n/a"
        print(
            f"{mode:<6}{len(group):>9}{sum(minutes) / 60:>8.1f}"
            f"{sum(minutes) / len(minutes):>9.0f}{max(minutes):>9.0f}{avg_queue:>11}"
        )
    packs = con.execute("SELECT hours, price FROM packs ORDER BY id").fetchall()
    if packs:
        print()
        for pack in packs:
            print(f"Pack: {pack['hours']:g} h for ${pack['price']:.2f} = ${pack['price'] / pack['hours']:.2f} per hour")


def cmd_list(args):
    con = connect()
    for row in done_sessions(con)[-args.limit:]:
        queue = f"{row['queue_min']:.1f}" if row["queue_min"] is not None else "n/a"
        print(f"#{row['id']:<4}{row['ended']:%Y-%m-%d %H:%M}  {row['mode']:<5}{row['minutes']:>7.1f} min  queue {queue:>5}  {row['game']}")


def cmd_export(args):
    con = connect()
    with open(args.path, "w", newline="") as handle:
        writer = csv.writer(handle)
        writer.writerow(["id", "game", "mode", "started_at", "live_at", "ended_at", "minutes"])
        for row in con.execute("SELECT * FROM sessions WHERE ended_at IS NOT NULL ORDER BY id"):
            writer.writerow([row[k] for k in row.keys()])
    print(f"Wrote {args.path}")


def build_parser():
    parser = argparse.ArgumentParser(prog="cloudhours", description=__doc__)
    sub = parser.add_subparsers(dest="command", required=True)

    p = sub.add_parser("init", help="create the config and database")
    p.add_argument("--plan", choices=sorted(PLAN_HOURS), required=True)
    p.add_argument("--allowance", type=float, help="override monthly hours")
    p.add_argument("--reset-day", type=reset_day_type, default=1)
    p.add_argument("--warn-pct", type=int, default=80)
    p.set_defaults(func=cmd_init)

    p = sub.add_parser("start", help="open a session when you press Play")
    p.add_argument("--game", required=True)
    p.add_argument("--mode", choices=MODES, default="plan")
    p.set_defaults(func=cmd_start)

    sub.add_parser("live", help="mark the moment the stream appears").set_defaults(func=cmd_live)
    sub.add_parser("stop", help="close the open session").set_defaults(func=cmd_stop)

    p = sub.add_parser("log", help="add a finished session by hand")
    p.add_argument("--game", required=True)
    p.add_argument("--mode", choices=MODES, default="plan")
    p.add_argument("--minutes", type=float, required=True)
    p.add_argument("--queue-min", type=float, default=0.0)
    p.add_argument("--ended", help="ISO time the session ended, default now")
    p.set_defaults(func=cmd_log)

    sub.add_parser("status", help="show the budget").set_defaults(func=cmd_status)

    p = sub.add_parser("pack-add", help="record a cloud playtime pack purchase")
    p.add_argument("--hours", type=float, required=True)
    p.add_argument("--price", type=float, required=True)
    p.add_argument("--when", help="ISO purchase time, default now")
    p.set_defaults(func=cmd_pack_add)

    p = sub.add_parser("report", help="per-mode statistics")
    p.add_argument("--all", action="store_true", help="include every period")
    p.set_defaults(func=cmd_report)

    p = sub.add_parser("list", help="show recent sessions")
    p.add_argument("--limit", type=int, default=10)
    p.set_defaults(func=cmd_list)

    p = sub.add_parser("export", help="write sessions to CSV")
    p.add_argument("path")
    p.set_defaults(func=cmd_export)
    return parser


def main(argv=None):
    args = build_parser().parse_args(argv)
    args.func(args)


if __name__ == "__main__":
    main()

The test file follows. It pins the clock with CLOUDHOURS_NOW, points CLOUDHOURS_HOME at a temporary folder, and rebuilds the database before each test.

import os
import tempfile
import unittest
from datetime import datetime

TMP = tempfile.mkdtemp()
os.environ["CLOUDHOURS_HOME"] = TMP
os.environ["CLOUDHOURS_NOW"] = "2026-11-10T20:00:00"

import cloudhours as ch  # noqa: E402


class PeriodTests(unittest.TestCase):
    def test_reset_day_one(self):
        start, end = ch.period_bounds(datetime(2026, 11, 10, 12).astimezone(), 1)
        self.assertEqual((start.month, start.day), (11, 1))
        self.assertEqual((end.month, end.day), (12, 1))

    def test_reset_day_mid_month_before_reset(self):
        start, end = ch.period_bounds(datetime(2026, 11, 10, 12).astimezone(), 15)
        self.assertEqual((start.month, start.day), (10, 15))
        self.assertEqual((end.month, end.day), (11, 15))

    def test_january_rolls_back_to_december(self):
        start, _ = ch.period_bounds(datetime(2027, 1, 3, 12).astimezone(), 10)
        self.assertEqual((start.year, start.month), (2026, 12))


class BudgetTests(unittest.TestCase):
    def setUp(self):
        for name in ("cloudhours.db", "config.json"):
            path = ch.home() / name
            if path.exists():
                path.unlink()
        ch.main(["init", "--plan", "premium"])

    def test_plan_minutes_add_up(self):
        ch.main(["log", "--game", "A", "--minutes", "90", "--ended", "2026-11-03T21:00"])
        ch.main(["log", "--game", "B", "--minutes", "30", "--ended", "2026-11-04T21:00"])
        con = ch.connect()
        status = ch.build_status(con, ch.load_config())
        self.assertAlmostEqual(status["used"], 120)
        self.assertAlmostEqual(status["left"], 480)

    def test_ads_do_not_touch_plan(self):
        ch.main(["log", "--game", "A", "--mode", "ads", "--minutes", "35", "--ended", "2026-11-03T21:00"])
        status = ch.build_status(ch.connect(), ch.load_config())
        self.assertEqual(status["used"], 0)
        self.assertEqual(status["ads_sessions"], 1)

    def test_pack_balance_survives_the_reset(self):
        ch.main(["pack-add", "--hours", "10", "--price", "3.99", "--when", "2026-10-25T10:00"])
        ch.main(["log", "--game", "A", "--mode", "pack", "--minutes", "60", "--ended", "2026-11-03T21:00"])
        status = ch.build_status(ch.connect(), ch.load_config())
        self.assertAlmostEqual(status["pack_left"], 540)

    def test_session_in_last_period_is_ignored(self):
        ch.main(["log", "--game", "A", "--minutes", "200", "--ended", "2026-10-28T21:00"])
        status = ch.build_status(ch.connect(), ch.load_config())
        self.assertEqual(status["used"], 0)


if __name__ == "__main__":
    unittest.main()

To run everything end to end, use these commands from the project folder:

python3 cloudhours.py init --plan premium
python3 cloudhours.py log --game "Sample Game A" --minutes 60 --queue-min 2
python3 cloudhours.py status
python3 -m unittest

Where This Leaves Free Cloud Gaming

The takeaway: free cloud gaming on Xbox is real but unmeasured. The ad-supported route is an Insider preview, the session limit has no published figure, and the paid packs carry introductory prices that may change. A tracker turns those unknowns into your own numbers.

Plan allowances are the firmer ground. Five, ten and fifteen hours a month are small enough that you should know where each one goes. Thirteen steps and about 45 minutes buy you that visibility, and the data keeps paying off every month afterward.

FAQ

Is Xbox cloud gaming free now?

Only in a limited way. Xbox began testing free cloud gaming with ads for Xbox Insiders on October 21, 2026, and the test does not require a Game Pass subscription. Outside that preview, you need a Game Pass plan or a cloud playtime pack. The preview has no published end date in the material I reviewed.

How many cloud hours does each Game Pass plan include?

Essential includes 5 hours a month, Premium includes 10 and Ultimate includes 15. Those limits were announced on September 3, 2026 for a November start. When you use up the included hours, you can buy more time through the Xbox Store.

Do free ad-supported sessions count against my plan hours?

Xbox describes the ad-supported route as a separate option for people without Game Pass, and the material I reviewed does not tie it to plan allowances. The tracker logs them in their own mode, so your plan total stays clean. Check your account counter to confirm how your region behaves.

How much do cloud playtime packs cost?

The introductory preview prices were $3.99 for 10 hours, $7.99 for 25 hours and $11.99 for 50 hours. That works out to about 40, 32 and 24 cents per hour. Microsoft has not confirmed final regional pricing, so expect changes.

Does PC Game Pass include cloud hours?

The Xbox pages I reviewed conflicted. One listing showed 15 cloud hours, and another said cloud gaming is not included. Check your own account page and use --plan custom --allowance with the number it shows.

Do unused hours roll over?

Xbox says pack playtime remains available until it is used, so pack balances carry forward, and the tracker never resets them. The material I reviewed does not say that plan hours roll over, so the tracker resets plan usage at each period boundary.

Does the tracker work on Windows and macOS?

Yes. It needs Python 3.11 or newer and nothing else. On Windows, use the py -3 launcher if python3 is missing, and set the CLOUDHOURS_HOME variable with PowerShell syntax if you want a custom data folder.

Marcus Chen

Marcus Chen

Gaming & Consumer Tech Editor

Marcus Chen is a senior editor at Tech Insider, where he leads coverage of the US online gaming market, including sweepstakes and social casinos, alongside consumer technology. He evaluates operators on their published terms, licensing and RNG certifications, stated redemption policies, and corroborating independent reporting, and writes plainly about what the evidence supports. Tech Insider does not run first-party money tests and does not gamble with reader funds. Marcus has reported on the technology and online-gaming industries for more than a decade.

View all articles

Source link

Leave a Reply

Your email address will not be published. Required fields are marked *