neural voxel engine
replacing analytic voxel physics with a tiny neural network
static void move_axis(Player *p, int axis,
float amount) {
float old = get_axis(p->pos, axis);
set_axis(&p->pos, axis, old + amount);
if (player_collides(p)) {
set_axis(&p->pos, axis, old);
if (axis == 0) p->vel.x = 0;
else if (axis == 1) {
if (p->vel.y < 0) p->grounded = 1;
p->vel.y = 0;
} else p->vel.z = 0;
}
}
the question
can AI be used to completely replace a physics engine?
I say why the hell not.
this page is an examination of my process for implementing this idea from top to bottom while frequently stopping to analyze the results and why things look the way they do.
starting with the game
step one was to build our world. naturally, I chose C and Raylib for my stack here. we simulate a 16x16x16 cube where each cell can be one of 4 types
our character is the orange box
with a playable world, we can extract a state each time step and generate ourselves some training data to use in our model.
gamestate object and model architecture
the game executable has a headless data capture mode that grabs current inputs, current position (xyz), velocity (xyz), a grounded flag and the 9x9x9 voxels surrounding the player.
we trained and will examine a number of models but we will mostly focus on ones that used 3x3x3 voxel observation as input.
after a little testing, I determined that two layers is sufficient for this task while keeping the models very small and fast. we could push the fidelity with more layers but for now, two works. we will examine different layer sizes shortly...
rules for training, inference, and benchmarks
- pure neural at runtime. no analytic fallback or corrections ever
- inference is done in the main thread in C. no pytorch outside of training
- fixed dt = 1/60. same in recording, training, and inference
- analytic is the reference. all comparisons should be considered a multiple of the analytic physics
- one step validation is not enough. we must study the divergence across multiple frames to get a true benchmark
these rules are meant to keep this a super honest evaluation of the models we build. in some early generations, before clarifying these rules, Cursor found all kinds of ways to cheat and artificially inflate the benchmark results. I recommend really thinking through your success criteria and considering how models might cheat or mislead your benchmark results.
when training AI we always deal with the models themselves finding ways to misbehave but win on benchmarks, agents will do the same thing unless you build and enforce an intelligent system of rules.
training basics
training follows the standard supervised training pipeline. we gather data from the actual game's physics engine and use pytorch to train a MLP.
loss is computed using MSE on the seven outputs, velocity, position, and grounded, but, position error is multiplied by 20, velocity by 2, grounded by 5.
these numbers were tuned during our evolution in addition to modifications to the dataset to represent certain cases more or less.
we also aim to train across multiple frames of a single simulation world. for a single step it was fairly easy to push accuracy as high as 99%. across a rollout however, it is much more difficult and almost certainly impossible if we only see one frame at a time.
single step accuracy is still an important metric for ensuring training has not collapsed but it tells us very very little about how the neural physics will actually play.
first stable models
the first model worth taking seriously was models/model.bin trained to see just one step at a time and the full 9x9x9.
- input - 9x9x9 voxels, position, velocity, grounded flag, WASD+jump (741 floats)
- architecture - 128×128 MLP, ReLU, 7 outputs (Δpos, next velocity, next grounded)
- loss - one-step MSE per frame: position x20, velocity x2, grounded x5
- export - fp32 weights + normalization stats to
model.bin
| metric | value |
|---|---|
| val pos RMSE | 0.00315 |
| val vel RMSE | 0.16 |
| grounded acc | 99.2% |
| neural step | 64 µs, compare to ~0.2 µs for analytic physics |
this looked great from the benchmarks, tiny positional error, 99% grounded accuracy but it played pretty bad...
this led to the development of the rollout benchmark. we needed to better measure how the game would actually play.
| metric | value |
|---|---|
| rollout pos error | 0.68 |
| tunneling | 941 / 12475 |
this shows a more accurate story, over 300 steps, we average 0.68 voxels off and 941 / 12475 (7.5%) of steps, we are inside a block and not grounded.
this matched the actual gameplay where we sink into terrain and jitter or slide when moving.
V2, 256x256 trained on 8-steps
in two steps, this iteration scaled the model up and implemented the multi step training I already detailed.
| metric | baseline 128×128 | rollout 256×256 |
|---|---|---|
| val pos RMSE (train) | 0.00315 | 0.00281 |
| val rollout pos 8-step (train) | — | 0.00458 |
| C rollout pos error | 0.68 | 0.46 |
| C rollout vel error | 2.62 | 1.84 |
| grounded mismatch | 18.6% | 10.9% |
| neural tunnel | 941 | 357 |
| step µs | ~66 | ~153 |
| × analytic | ~330× | ~765× |
rollout training moved every metric, pos error 0.68 -> 0.46, tunnelling 941 -> 357, grounded mismatch cut nearly in half. just to illustrate again the invalidity of the one-step benchmarks, the val RMSE hardly moved. the cost of this new model was speed: ~153 µs/step vs ~66 µs for the smaller one, my focus was first to push the model to high fidelity, then optimize.
this was also a huge step for the gameplay quality, things were now pretty stable but not perfect, the jitter and phasing are still present especially around edge cases.
V3, better dataset coverage
models/model_rollout_v2.bin - same 256×256, 8-step rollout as v2, but trained on a modified training set.
I figured that a lot of the jitter and sliding when standing still was due to a lack of just standing in the dataset, despite real human gameplay having lots of idle time.
I also theorized that the small upward velocity correction when standing on the ground has some effect on the character bouncing constantly during gameplay.
I added direct idle gameplay coverage to the data collection spread, as well as a tweak when recording so that we did not report the micro adjustments when grounded.
| metric | rollout v1 | rollout v2 |
|---|---|---|
| val pos RMSE | 0.00281 | 0.00217 |
| val rollout pos 8-step | 0.00458 | 0.00272 |
| grounded acc | 99.2% | 99.8% |
| C rollout pos error | 0.46 | 0.42 |
| C rollout vel error | 1.84 | 1.08 |
| grounded mismatch | 10.9% | 8.7% |
| neural tunnel | 357 | 420 |
I did not know what to expect as far as the effect on the benchmarks for these changes. that said, we see solid improvements on everything except for the tunneling
in gameplay this is also much stronger, still some sinking but this is very playable now.
At this point there were a few upgrades I thought about for the dataset along with a size increase but the training duration was far too long and beginning to hit memory limits on my laptop. I created a V3 dataset detailed in the chart below but we will save it for our next generation. down the next rabbit hole...
| dataset | idle / no-input | random combo | single direction | forward+jump | edge-walk | airborne fall/drift |
|---|---|---|---|---|---|---|
| v1 | 5.0 | 50.0 | 30.0 | 15.0 | 0.0 | 0.0 |
| v2 final | 23.4 | 41.4 | 22.0 | 13.2 | 0.0 | 0.0 |
| v3 grounded-start | 23.5 | 27.0 | 22.5 | 13.5 | 13.5 | 0.0 |
| v3 airborne-start | 9.0 | 18.0 | 15.0 | 9.0 | 9.0 | 40.0 |
this just goes to show the effect and level of tuning that datasets can give you when training the models, hyperparameters and even loss functions are not everything.
patch size sweep
one thing I thought we could very easily optimize was the patch size (number of voxels input) we give to the network at each step. this was making training super slow and theoretically, it makes very little sense that we would ever need to know anything about voxels besides the ones we could theoretically touch in this or the next frame.
so I went and trained 128x128 models at every patch size from 9x9x9 down to 2x2x2; (15 epochs, 200k samples).
the goal was just to evidence that we wouldnt have steep performance drop offs while we made each model exponentially smaller and easier to train. below are the somewhat interesting results.
I wanted to investigate why the error was not in line with my expectations. the amount of noise in the sweep led me to believe that there were some dials we could play with to at least find a real pattern here.
my main suspicion was that my initial sweep was underfitted so I bumped the training steps, I also increased the dimensionality of each model to ensure that wasnt the issue.
my current 'situation' does not include an easy to access graphics card so I only ran this for a few of these models to save some time, the results are below.
| patch | old sweep pos | new pos | weight KB | step µs | gnd mm% |
|---|---|---|---|---|---|
| 3³ anchor | 1.313 | 0.335 | 304 | 9.9 | 9.9 |
| 4³ retrain | 0.538 | 0.351 | 342 | 9.9 | 10.9 |
| 5³ retrain | 0.627 | 0.374 | 403 | 10.3 | 11.3 |
| 6³ retrain | 1.208 | 0.387 | 495 | 12.0 | 11.9 |
with this making sense, the fidelity being pretty damn good, and a known good recipe, we can move on to optimizations and pushing these models to their limit.
pushing the 3x3x3
I chose the 3x3x3 as the model to continue pushing. there are a few goals,
- see how fast we can go
- see how accurate we can get
- see how small we can make it
I set off a few training runs at different sizes and architectures, all using the v3 dataset, and here are the successful models.
models to highlight:
patch3/256_rollout8_idle4.bin- fidelity anchor. 0.335 pos, 9.9% grounded mismatch, 231 tunnel steps. plays great.patch3/64_rollout4_fast.bin- speed floor. 0.476 pos at 5.7µs/step. this one still beats the old 9x9x9 baseline in accuracy.
final optimization push
there are a few things we can do in C to push inference speeds for all of our models without touching weights or retraining.
fused matmul + compiler optimizations
layer-1 normalized into a temp buffer then take a dot product with w1. we can fold normalization calculation right into that calculation to remove the entire whole pass and we can do it on the denormalization step as well. we also prefetch the next row with this cool macro:
#define NNPREFETCH(p) __builtin_prefetch((p), 0, 3)
of course we also flip the compiler to Release -O3 -ffast-math -funroll-loops -march=native
this is basically free and it results in 72 µs → 11.9 µs (~6×) speed increase for the 256_rollout8_idle4 model.
AVX2 SIMD
this is over the top but I went ahead and implemented AVX2-FMA kernels: 8-wide FP32 dots with runtime dispatch via __builtin_cpu_supports.
I briefly tried VNNI int8 but rollout instantly broke and I could not fix it. real int8 speed probably needs QAT and integer dots. our post training quantization is not compatible, more on that in the next section.
verdict: absolutely worth doing the fused matmul pass, free speedup and not too much work. the AVX2 is questionable, more worthwhile the bigger your model is but is caused all kinds of issues and for the smaller models I was targeting, only a small speedup.
quantization
this part is pretty fun, we will consider the following five schemes: fp32, int8_row, int8_layer, int4_row, fp16.
we are quantizing after training here rather than during. from my research this does introduce potential for greater cumulative errors and I think this is the issue that prevented the VNNI tests from working.
final comparisons
this is every model we trained. the rollout benchmark runs 50 ep × 300 steps, post–AVX2 step times unless noted.
quantized versions are not shown.
| model | patch | MLP | train | pos | gnd mm% | tunnel | µs | KB |
|---|---|---|---|---|---|---|---|---|
analytic | — | — | — | 0 | 0 | 0 | 0.2 | 0 |
| 9³ full-context lineage | ||||||||
model.bin | 9³ | 128×128 | — | 0.632 | 14.8 | 798 | 15.5 | 445 |
model_rollout | 9³ | 256×256 | — | 0.576 | 14.3 | 586 | 29.5 | 1012 |
model_rollout_v2 | 9³ | 256×256 | — | 0.530 | 13.2 | 868 | 31.4 | 1012 |
| 3³ experiments (256×256 idle4 recipe family) | ||||||||
patch3/256_rollout8_idle4 ★ | 3³ | 256×256 | 13m | 0.335 | 9.9 | 231 | 9.9 | 304 |
patch3/512x256_rollout8 | 3³ | 512×256 | 16m | 0.307 | 13.5 | 686 | 16.5 | 600 |
patch3/256_rollout8_30ep | 3³ | 256×256 | 11m | 0.414 | 18.0 | 613 | 7.7 | 304 |
patch3/64_rollout4_fast | 3³ | 64×64 | 5m | 0.425 | 10.5 | 191 | 1.0 | 28 |
patch3/128_rollout4_30ep | 3³ | 128×128 | 5m | 0.578 | 22.7 | 574 | 2.8 | 88 |
patch3/256_rollout12_25ep | 3³ | 256×256 | 32m | 0.553 | 13.6 | 617 | 7.3 | 304 |
| idle4 retrain · 256×256 · 30 ep × 400 batches (today) | ||||||||
patch_sweep_retrain/patch_4 | 4³ | 256×256 | 9m | 0.351 | 10.9 | — | 9.9 | 342 |
patch_sweep_retrain/patch_5 | 5³ | 256×256 | 7m | 0.374 | 11.3 | — | 10.3 | 403 |
patch_sweep_retrain/patch_6 | 6³ | 256×256 | 5m | 0.387 | 11.9 | — | 12.0 | 495 |
| patch sweep · 128×128 · 15 ep (under-trained baseline) | ||||||||
patch_sweep/patch_2 | 2³ | 128×128 | ~2m | 0.572 | 11.9 | 334 | 1.9 | 79 |
patch_sweep/patch_3 | 3³ | 128×128 | ~2m | 1.915 | 17.1 | 235 | 3.0 | 88 |
patch_sweep/patch_4 | 4³ | 128×128 | ~3m | 0.863 | 17.5 | 222 | 2.6 | 107 |
patch_sweep/patch_5 | 5³ | 128×128 | ~3m | 0.810 | 12.0 | 170 | 4.1 | 138 |
patch_sweep/patch_6 | 6³ | 128×128 | ~4m | 1.527 | 13.7 | 159 | 4.5 | 184 |
patch_sweep/patch_7 | 7³ | 128×128 | ~6m | 1.028 | 32.2 | 384 | 7.1 | 249 |
patch_sweep/patch_8 | 8³ | 128×128 | ~8m | 1.881 | 32.2 | 214 | 9.5 | 335 |
patch_sweep/patch_9 | 9³ | 128×128 | ~10m | 0.847 | 18.1 | 194 | 16.2 | 445 |
idle4 anchor is probably the sweet spot ~10 µs, 0.335 pos, 304 KB.
4³–6³ idle4 retrains cluster right next to it (0.35–0.39 pos at similar speed).
64x64 is our observed speed floor (~1 µs) with high accuracy still.
512×256 is the accuracy ceiling on 3³.
9³ v2 loses on both axes we care about.
sweep models are noise, some score great, some bad, none actually useful.
building with cursor
I wrote a single spec.md and drove ~16 rough phases through Cursor agents.
agents are awesome and they push the speed of development unreasonably fast, but you need to stay on top of them. Cursor tried to cheat on the benchmarks, implement weird overrides, train models that made no sense, and otherwise just screw around.
the biggest benefit is the ability to come up with a hypothesis and tell cursor to go test it with N subagents. I can also ask for suggestions or benchmarking scripts or really anything I want and its basically done. I also would not have been able to generate all of the cute charts and graphs you see in this post without AI, too tedious for me...
keeping your agents on track and doing what you want is a skill, especially when you manage lots of them at a time. you need to write intelligent prompts as well as repo level rules to enforce your standards.
if I knew nothing about machine learning or physics engines, I dont think I could have directed any AI model through this thing. Taste and actual knowhow are still important skills in the age of AI and those who have those things and know how to intelligently use tools like Cursor or Claude Code will own the day.
whats next
I loved this project! Im probably not done with it yet and I think different architectures or better training could push the loss a lot lower.
to our initial question: "can AI completely replace a physics engine" I say yes. I do not say that it will be as performant or robust in all edge cases but it is not impossible. suppose a company that has millions of hours of training data in their game engine, there is a ton of potential there. (server sized GPU could do inference for an entire playerbase in batches. good enough and avoids expensive server side CPU physics, you could train an even cheaper classifier model to just detect people exploiting movement ... food for thought)
my neural physics engine is now definitely playable but also very quirky, sometimes you can spam jump forever or phase through the map, but on average, we can move and interact with the voxel world semi convincingly.
the idea that neural networks are universal function approximators is very interesting. for all tasks, including this one, there theoretically is a network that can perfectly approximate the algorithm. building a training pipeline is a whole other question but as shown here, it can be done.
a project Ive had in mind forever is to simulate a little terrarium of some sort and slowly swap out algorithmic systems for neural ones. imagine a mini food chain that follows rules but creature by creature, I swap in a tiny neural network. This all may even build to unique little world models that simulate every interaction in the terrarium one forward pass at a time. very cool!
if you found this interesting or have some idea about it you want to share, please email me @ [email protected] I love getting emails.
find the repo here