Slow Start:把每日步數目標設計成 TCP 擁塞控制
A lazy day is just packet loss. So my step goal backs off like TCP.
Slow Start 是一款開源健身遊戲,按 TCP 式調節每日步數目標:達標後上調,未達標則減半。在驗證集上,它有 71.1% 的日子達標,目標為平日步數的 74%,與「個人第 30 百分位」規則的 69.7% 相若。TabPFN v2 預測明日步數並限制目標,Gemma 3 4B 負責回應每日記錄;兩者均在作者控制的伺服器運行。
This is a submission for the Hacktoberfest Open-Source AI Challenge Week 3: Slow and Steady.
Before writing this post I exported my own last 90 days of steps. I reached 10,000 on none of them. Not
one day, in a stretch where I logged 43 workouts in Hevy. Every fitness app I have used still opens with the
same number, and a goal you never reach is not a goal. It is a reason to stop opening the app.
I work on message queues for a living, so I spend my days around a very old idea: when you do not know how
much a network can take, start slow, speed up while things go well, and back off when something is lost.
That is TCP congestion control. Then I remembered that a monster from a game I love has an ability with the
same name, Slow Start: it halves its stats for its first five turns, then never stops.
So I built a fitness game for lazy people where your daily goal works like TCP, an open model keeps it
honest, and a sleepy stone giant wakes up as you walk.
TL;DR
- Slow Start sets your step goal like TCP: start small (1,500, or your own level if you import your history), double after a win, climb steadily near your level, halve after a miss, never to zero. TabPFN v2, an open tabular model, caps the goal at a level you reach about 7 days in 10. You never set a goal yourself.
- The game: every day a wild creature blocks the path. Its health is your goal, every step you log is a hit, and caught creatures follow your giant, Slumbrock, home. All pixel art is original and drawn in code.
- The honest result: on held-out days from 33 real people, a fixed 10,000 steps was met on 34.6% of days. My first controller hit 86.6%, but only by asking for half of what people usually walk. Tuned on a validation window, Slow Start meets 71.1% of days at 74% of a usual day, which ties a plain "your own 30th percentile" rule (69.7%). On my own 90 days: 71.1% against 0% for 10,000.
- Open, end to end: TabPFN v2 (Apache 2.0) and Gemma 3 4B run on a server I control. Nothing goes to a closed AI service. Web, Android APK and source are all public.
What I Built
Slow Start is a web game and an Android app with three ideas inside:
- A goal you can win, that moves with you. The step controller is TCP's congestion window. A second limit, TabPFN's prediction of tomorrow, plays the receiver window. Your goal is the lower of the two.
- A reason to come back. Slumbrock, a stone giant asleep for three hundred years, wakes through four stages as you earn XP. His Speed and Power both start at half, like his namesake.
- No decisions. The goal arrives every morning. If you lift, the next weights and reps arrive too. Walking alone is a full way to play, and a sick day is excused, not punished.
| TCP | Slow Start |
|---|---|
| Congestion window, grown by the sender | Window: the controller's goal |
| Receiver window, advertised by the receiver | Cap: TabPFN's 30th percentile of tomorrow's steps |
| Effective window = min(cwnd, rwnd) | Goal = min(window, cap) |
| Slow start: double per round trip | Double the goal after a won day |
| Congestion avoidance: add one per round trip | Add 1,500 steps per won day once near your level |
| Loss: halve, set ssthresh | Missed day: halve the window, remember the level |
| Cached metrics for a host seen before | Imported history: start from your own level, not 1,500 |
Demo
- Play it: https://slowstart.onrender.com. No account. Scroll the first screen slowly and watch him wake.
- Watch the demo: one real person's 40 days from the public dataset, one tap per day. You can see the goal double, get capped, halve after a miss and climb back.
- The field guide: every creature, where it lives, how to catch it, and trivia nobody asked for.
- Bring your own steps: the export guide walks through Google Fit, a Fitbit or Pixel Watch, Hevy or any CSV, and the import sits right on the game's Today screen. If your file comes out wrong, the app links to a GitHub issue form pre-filled with what it saw, and the case becomes a rule in the cleaner.
- Android: the APK is the same build wrapped with Capacitor.
Things to try:
- tap Slumbrock and the creature.
- Open Why this number? under the goal.
- On the Talk tab, tell Slumbrock how your day went.
Code
Tanay3484
/
slow-start
Slow Start: TCP-style goals for lazy people, with a pixel-art giant to look after (DEV Hacktoberfest 2026 Week 3)
Slow Start
A fitness game for lazy people. Your daily step goal and your next workout are set the way TCP sets how fast a connection may send: start small, double after each success, grow slowly once you're near your level, and halve after a miss, never to zero. A second limit, TabPFN's prediction of what you'll probably manage tomorrow, stops it asking for too much on a day the history says will be a low one.
You look after Slumbrock, a pixel-art stone giant that wakes up as you keep going. The idea comes from a monster whose ability, also called Slow Start, halves its stats for its first five turns. The creature art and names here are original; nothing is taken from Pokémon or Nintendo.
Built for the DEV Hacktoberfest 2026 Open-Source AI Challenge, Week 3: Slow and Steady.
Live: https://slowstart.onrender.com (the game is at /play…
backend/ is Python (FastAPI, TabPFN v2, Gemma through Ollama). app/ is TypeScript with no framework: a
small game engine on a 240 × 135 canvas, wrapped as an Android app with Capacitor. Every number in this post
comes from a script in scripts/ and a file in results/. MIT licensed.
How I Built It
Code decides, the models advise
The rule I kept from my earlier weeks: anything that sets a number is plain, tested code.
- The controller is a pure function of your history. It replays every day, so the same history always gives the same goal.
- TabPFN v2 gets a few features (the last three days, the week's mean and minimum, the fortnight's median, the weekday) and predicts a distribution for tomorrow. The cap is its 30th percentile. It is fitted on your own earlier days plus a small public prior, so a new user is not starting from nothing.
- Gemma 3 4B reads a one-line check-in ("leg day was brutal") and answers in Slumbrock's voice. Code, not the model, decides whether the day is excused (a word list with a negation check) and picks the recovery tip from a curated list, so nothing medical is ever made up.
On Render's two CPUs Gemma 4B takes about fifteen seconds to answer. Slumbrock is a sleepy rock, so
the button says he is thinking, and if a reply comes back malformed a template line takes over.
The game engine, all in code
There are no image files for the creatures. scripts/draw_sprites.py draws them from shaded shapes: spheres
and bevelled blocks lit from the top left, a four-tone ramp with a little dithering, outlines in the darkest
tone of each colour. Each creature has idle frames (wings flap, Kettlebub lifts its little bar), a blink and
a hit face. The arena is about 900 lines of TypeScript: an entrance for each new creature, an attack with a
damage number for every step you log, a capture after which the creature follows you, an evolution burst,
and weather and time of day per place. Text is drawn on a second, full-resolution canvas layered on top,
because pixel-font text scaled up from 240 × 135 was an unreadable blur.
The evidence
The data is the public Fitbit Fitness Tracker Data on Kaggle (CC0): daily steps from 35 people in
spring 2016. Of 1,373 person-days, 133 have exactly zero steps. I treat those as "watch not worn", not
scored and not fed to any policy.
Every policy sets each day's goal seeing only the days before it, then the day is revealed. TabPFN is fitted
per calendar day on everyone's earlier days only. A test proves no policy is ever shown the day it is
setting a goal for. Two windows: a validation window (the 14 days before 2016-04-28) for choices, and a
final window (the last 14 days) run once at the end.
Two numbers per policy, because either alone can be gamed:
- Goal met: the share of days the person reached the goal.
- Asks for: the goal as a share of the person's usual day. A policy that wins by setting tiny goals shows up here.
Final window, 32 people, 350 person-days:
| Goal | Goal met | Asks for | Met vs plain 30th percentile (95% CI) |
|---|---|---|---|
| Fixed 10,000 | 34.6% | 148% | −35.1 pts (−48.3 to −21.6) |
| Your 28-day median | 46.3% | 101% | −23.4 pts (−28.7 to −18.6) |
| Your 28-day 30th percentile | 69.7% | 79% | (baseline) |
| TabPFN 30th percentile alone | 66.6% | 84% | −3.1 pts (−9.1 to +2.2) |
| Slow Start (controller + TabPFN cap) | 71.1% | 74% | +1.4 pts (−4.8 to +7.4) |
The intervals resample people, not days, because one person's days are not independent.
What went wrong, and what I did about it
- My first controller won by cheating. With a climb of 250 steps per win, it met 86.6% of goals, which looked great until the guardrail column said it was asking for 50% of a usual day. The TCP sawtooth was the reason: after every halving, climbing back 250 steps a day takes weeks, so the goal sat at half your level most of the time. I swept the climb (250 to 1,500) on the validation window under a rule I wrote down before running it: the largest step that still meets at least 70% of days. That was 1,500, the top of the range I tried, so a larger one might pass too. Then I ran it once on the final window.
- TabPFN did not beat a percentile. On the validation window TabPFN's cap was ahead of the plain 30th percentile by 3.9 points; on the final window it was behind by 3.1. The sign flips, so on 33 people the two are a tie. I also tried a second feature set (everything relative to the person's own median) and picked between them on validation by a rule written before I saw the second result. It made no difference.
-
Gemma answered "rest". One word. The prompt asked for one short sentence, and the small model took that
literally. Now Gemma writes the reaction with a few examples of Slumbrock's voice, code adds the tip, and
one reply in three gets a rock joke. Gemma 4B once ended a reply with a curly quote and eighteen
</div>tags; replies are now cut at a stray quote, brace or tag. - "Walked to work and back" got a tip for a sore back. Words like "back" and "head" now only count as body parts in context ("my back", "back is stiff").
-
My own statistics function would have crashed on real data. The bootstrap built a list of lists where
it needed a flat list of days. The tests passed because every test person had the same number of days. A
new test with uneven people caught it, and the fix was one word (
extend, notappend). - Not tested: whether these goals change what people do. A backtest replays what people did without the app. The public dataset is short (about two months) and from 2016; my own is one person.
Then I ran it on myself
The public set is 33 strangers from 2016. So I exported my own history: Google Fit steps since 2024 (1,028
days) and my Hevy log (633 workouts since 2022). My own data broke the app four times:
-
The importer refused my steps file. Google Fit calls the column
Step count, notsteps. The Takeout folder also has a raw stream per device, and adding them up would have counted every step twice, because my phone and my watch overlap. The importer now reads the right file and refuses the wrong ones with a message naming the right one. - Three years of history broke the goal. The app sent every stored day to the server, which refuses more than 800. It now sends the last 365.
- Most of my steps are a phone in a pocket. The watch reached Google Fit on only 42 of those days, so a day with the phone left on a desk counts as a few hundred steps. Under 500 now counts as "not carried": rest, not a miss. I wrote that line down before running the backtest, and every upload gets the same cleaning now.
- Then the import changed nothing. I loaded all 1,028 days into the live game and it still gave me 1,500. Imported days carry no goal, so the controller had nothing to replay and started from scratch, as if I had never walked. Now, like TCP reusing what it learned about a host it has seen before, the controller starts from your own 30th percentile of the last 28 imported days. On my history that is 4,100, just under TabPFN's cap of 4,150.
My last 90 days, held out (TabPFN v2 fitted 118 times, median 5.0 s each):
| Goal | Goal met | Asks for | Met vs plain 30th percentile (95% CI) |
|---|---|---|---|
| Fixed 10,000 | 0.0% | 301% | −68.9 pts (−75.6 to −61.5) |
| My 28-day median | 52.2% | 116% | −16.7 pts (−24.4 to −9.9) |
| My 28-day 30th percentile | 68.9% | 82% | (baseline) |
| TabPFN 30th percentile alone | 68.9% | 89% | +0.0 pts (−6.7 to +5.6) |
| Slow Start | 71.1% | 85% | +2.2 pts (−5.5 to +8.8) |
With one person the interval resamples weeks, not people. A fixed 10,000 was met on none of my 90 days.
Slow Start and the plain percentile tie again, as they did on 33 strangers. On my data TabPFN alone met
exactly as often as the percentile while asking for a little more (89% of a usual day against 82%).
The workouts broke my exercise controller. It was built for a beginner's programme: three sets of eight
at one weight, plus 2.5 kg after a good session. I do not train like that. I work up to one heavy top set
(a deadlift day is 100 × 4, 140 × 3, 140 × 2, 100 × 6), so "repeat last session" was met 0% of the time,
and the controller "won" by cutting the weight to 12% of what I lift. The guardrail caught it, as it did
for steps. I changed the spec to score the top set instead, and reran:
| Scored on the top set (exploratory) | Target met | Asks for vs last time |
|---|---|---|
| Repeat last top set | 31.4% | 100% |
| Add a step every session (2.5 kg, or one rep) | 8.6% | 106% |
| Controller | 77.1% | 82% |
That design came after seeing my log, on the same log, so it is exploratory, not a held-out result. It is
35 sessions across six lifts.
One more thing only real use found: anyone who had watched the demo once was stuck in it. The Play button
kept reopening the demo, and the way out was hidden in a settings tab. I only noticed because I tried to
start for real with my own data.
Built with an agent, with four functions by hand
I built this with Claude Code under a spec-first workflow: brief, requirements, design and tasks, each
reviewed before code. Four core functions were set aside to write by hand:
| Level | What | Written by |
|---|---|---|
| H1 | The step controller | me, from a skeleton |
| H3 | The evaluation statistics (goal level, people-resampled intervals) | me, from a skeleton |
| H2 | The exercise controller | Claude, when I ran out of time |
| H4 | The XP and stage rule | Claude, when I ran out of time |
Why Does Open Innovation Matter?
- Health data stays with you. Your steps, workouts and check-ins go only to a server I run, which processes each request in memory and keeps nothing. Everything is saved on your device.
- The open version was the only usable one. TabPFN 2.5 and later are licensed for non-commercial use only. TabPFN v2 is Apache 2.0, so the app pins v2 and bakes the weights into the image. A predictor I can download, run on a plain CPU and check is one I can actually ship.
- No price per call. A goal costs about five seconds of CPU (measured in the container with no network, 1 CPU and 2 GB). There is no per-request bill to make the "lazy" goal expensive.
- Swappable and checkable. The predictor and the language model sit behind small interfaces, and the data, the weights and the evaluation script are all public, so anyone can rerun the tables above.
The Lazy Payoff
You make zero decisions. The app sets one step goal a day, and one target per exercise per session if
you lift.
In my last 90 days on Hevy I logged 43 workouts: 327 times I picked an exercise and a weight, and 685
working sets. Slow Start plans only the main lifts, so it takes about one decision in ten off me (35 of
them); the machines and cables are still mine. For steps it is every decision: one number a day, won 7
days in 10 on my data, where a fixed 10,000 was won on none. Lower goals are easier to win, which is exactly
why every table also shows how much each goal asks for.
Prize Categories
Best Use of TabPFN
TabPFN v2 is the receiver window: the half of the TCP analogy that knows what you can take tomorrow.
-
What it does on every goal. The game asks for today's goal, and TabPFN gets a handful of features
(the last three days, the week's mean and minimum, the fortnight's median, the weekday). It is fitted in
context on your own earlier days plus 400 rows from a public prior, so someone with a week of history is
not starting from nothing. It returns quantiles, not a point. The cap is the 30th percentile, a level
you reach about 7 days in 10, and the goal is
min(controller window, cap). - Why TabPFN and not a model I train. There is nothing to train per person. One person has a few weeks of rows, far too few to fit a model, but TabPFN does in-context learning on exactly that kind of small table and gives calibrated quantiles out of the box.
- Why v2. It is Apache 2.0. TabPFN 2.5 and later are non-commercial, so the app pins v2 and bakes the weights into the Docker image at build time.
- How it runs. It runs on CPU with four estimators. I measured about 5 seconds per goal, 598 MB at peak and 2.9 s to load, in a container with no network, 1 CPU and 2 GB. On Render a goal takes 5 to 6.5 s. If it fails or times out, the cap falls back to 80% of your recent median, and the app says so.
- How I tested it. Walk-forward on 33 people, with TabPFN refitted per calendar day on earlier days only. Two feature sets were picked on a validation window by a rule written before the second result came in. Against a plain "your own 30th percentile" it is a tie, and I report it as one. On my own last 90 days, with the product's own code, it ran 118 fits at a median of 5.0 s each. It met exactly as often as the percentile (68.9%) while asking for a little more of a usual day (89% against 82%).
Best Use of Gemma
Gemma 3 4B is Slumbrock's voice. You tell him how the day went ("upper body is really sore from weighted
pull ups yesterday"), and he answers in character.
- Self-hosted. It runs on Ollama, on Render, with the model baked into the image so a restart never re-downloads it. No check-in ever leaves a server I run.
-
What Gemma decides, and what it does not. Under a JSON schema Gemma returns three things: an energy
level, the body parts mentioned and one reply line. Code checks each of them:
- A body part is kept only if it is a known one and the person actually wrote it, so Gemma cannot invent a sore knee.
- The reply must be 4 to 40 words with no digits, so it can never state a number, a dose or a goal.
- Whether the day is excused comes from a word list with a negation check ("not sick" is not sick), not from the model.
- The recovery tip comes from a curated list chosen by code, so nothing medical is made up.
- The rock jokes ("You are, to be clear, talking to a rock.") are added by code, about one reply in three, never on a sick day.
-
Taming a small model. Gemma's first answer was one word: "rest". A few examples of Slumbrock's voice
in the prompt fixed that. Gemma 4B once ended a reply with a curly quote and eighteen
</div>tags, so output is capped (num_predict240,maxLength220) and cut at the first stray quote, brace or tag. Anything that fails the checks falls back to a template line, so a check-in never breaks. - Cost of thinking. About 15 seconds a reply on Render's 2 CPUs. I tried 1B on a smaller plan and its replies were noticeably flatter, so the 4B earned its plan.
Best Use of Render
One Blueprint (render.yaml) brings up the whole product: two services, a private link between them and
a health check.
-
slowstart, a web service (Docker, 1 CPU / 2 GB). One container serves the FastAPI API, the built game and site, and TabPFN v2. A multi-stage Dockerfile builds the TypeScript app with Node, then bakes the TabPFN weights into the Python image, so the service starts with no downloads. 1c-2g is the smallest plan that fits: TabPFN with torch peaked at 598 MB, too much for a 512 MB plan. -
slowstart-ollama, a private service (Docker, 2 CPUs / 4 GB) with Gemma 3 4B baked in. It has no public URL. The web service reaches it over Render's private network, viafromService … hostportin the Blueprint. -
Lessons the deploy taught me, all written into the Blueprint.
- The private service needs
PORT=11434, or the hostport defaults to 10000 and every model call is refused. - Inside the container llama.cpp sees the whole host, not the plan, so
LLAMA_ARG_THREADS=2,LLAMA_ARG_CACHE_RAM=0andLLAMA_ARG_NO_REPACK=1keep it inside 4 GB. -
OLLAMA_KEEP_ALIVE=-1keeps the model loaded between check-ins.
- The private service needs
-
Shipping by pull request. Both services deploy from
mainwith auto-deploy on, behind/api/health. Every fix in this post was a merged PR that was live a few minutes later, including the ones my own data forced on deploy night. - Stateless on purpose. The server keeps nothing: every request carries the history it needs, and your data lives on your device. A restart or a redeploy loses no one's progress.
來源:DEV Community · dev.to






