<- back to blog / ryanhub

neural voxel engine

replacing analytic voxel physics with a tiny neural network

by Ryan Alport Jun 21, 2026 repo

in hidden out
vs
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

controls: WASD move | Space jump | R new map | Tab toggle analytic/neural | N reload model

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.

anchor: patch3/256_rollout8_idle4.bin 39 inputs 27 voxels 3x3x3 patch 3 offsets sub-voxel pos 3 velocity 1 grounded 5 buttons WASD + jump dt + scalars 256 hidden ReLU fused norm layer 1 256 hidden ReLU layer 2 8-step rollout 7 outputs dx, dy, dz next vx, vy, vz next grounded applied directly no collision fix ~10 us/step (AVX2) | 304 KB fp32 | 153 KB fp16 | 13 min train

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

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.

teacher-forced rollout (K consecutive frames from one episode) obs₀ recorded MLP obs₁ MLP obs K-1 MLP target Δpos, vel, grounded step loss (each frame): 20·MSE(Δpos) + 2·MSE(vel) + 5·MSE(grounded) × sample weight + rollout term: rollout-weight · MSE(cum Δpos) + 0.5·rollout-weight · MSE(vel sequence)
each batch samples K consecutive frames from one recorded episode. the model is scored on every step, plus how wrong the accumulated position drift is over the window.

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.

Gameplay with first stable model.bin
metricvalue
val pos RMSE0.00315
val vel RMSE0.16
grounded acc99.2%
neural step64 µ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.

metricvalue
rollout pos error0.68
tunneling941 / 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.

Gameplay with rollout-trained 256×256 model
metric baseline 128×128 rollout 256×256
val pos RMSE (train)0.003150.00281
val rollout pos 8-step (train)0.00458
C rollout pos error0.680.46
C rollout vel error2.621.84
grounded mismatch18.6%10.9%
neural tunnel941357
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.

Gameplay with model_rollout_v2 idle dataset

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 RMSE0.002810.00217
val rollout pos 8-step0.004580.00272
grounded acc99.2%99.8%
C rollout pos error0.460.42
C rollout vel error1.841.08
grounded mismatch10.9%8.7%
neural tunnel357420

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...

effective recorder policy · each row sums to 100%
dataset idle / no-input random combo single direction forward+jump edge-walk airborne fall/drift
v15.050.030.015.00.00.0
v2 final23.441.422.013.20.00.0
v3 grounded-start23.527.022.513.513.50.0
v3 airborne-start9.018.015.09.09.040.0
idle random direction fwd+jump edge fall/drift v1 v2 v3 g grounded v3 a airborne v3 is state-dependent: grounded → 10% idle branch, else movement mix airborne → 40% fall/drift branch first, else a thinner movement mix
v3 is interesting, the recorder picks different odds when grounded vs airborne. the aim was to better capture different edge cases. I later considered even another branch for colliding but it was never implemented.
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.

patch sweep · rollout error
patch sweep · step time
training step times drop very expectedly but the error results are confusing, they are all over the place.

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.

256x256 idle4 retrain; patches 3³–6³ · C benchmark 50×300
patch old sweep pos new pos weight KB step µs gnd mm%
3³ anchor1.3130.3353049.99.9
4³ retrain0.5380.3513429.910.9
5³ retrain0.6270.37440310.311.3
6³ retrain1.2080.38749512.011.9
the 256_batch_8_idle4 class of models now match my initial expectation of size reduction and negligible quality dropoff.

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,

I set off a few training runs at different sizes and architectures, all using the v3 dataset, and here are the successful models.

speed vs accuracy vs weight
0 .2 .4 .6 0 70 140 210 280 step time (µs / step) → lower is faster rollout position error → lower is better analytic 64_rollout4_fast 128_rollout4_30ep 256_rollout8_idle4 256_rollout8_30ep 9³ v2
each point is one model: horizontal = inference step time, vertical = rollout position error, bubble area = file size.

models to highlight:

Gameplay with 3×3×3 idle4 anchor model

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.

C optimizations · pre vs post · end-to-end neural step (µs)
pre-opt (fp32) post-opt (fused matmul + Release) 0 280 72 12 3³ anchor ★ 0.335 pos 5.7 1.9 3³ 64×64 0.476 pos 268 55 9³ v2 0.530 pos

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.

full arc on three representative models: baseline → C opts → AVX2.

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.

fp32
size 304 KB
pos err 0.335
speed 12.1µs
default
fp16
size 153 KB
pos err 0.343 (+0.008)
speed 15.1µs
best size/fidelity tradeoff
int8_row
size 80 KB
pos err 0.391 (+0.056)
speed 36.8µs
good compression but we would need to implement QAT to see speed gains
int8_layer
size 78 KB
pos err 0.564 (+0.229)
speed 14.8µs
probably too much drift to be generally useful
int4_row
size 42 KB
pos err 0.658 (+0.323)
speed 339.5µs
tiny file but forces the CPU to unpack nibbles which is very slow

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
analytic0000.20
9³ full-context lineage
model.bin128×1280.63214.879815.5445
model_rollout256×2560.57614.358629.51012
model_rollout_v2256×2560.53013.286831.41012
3³ experiments (256×256 idle4 recipe family)
patch3/256_rollout8_idle4256×25613m0.3359.92319.9304
patch3/512x256_rollout8512×25616m0.30713.568616.5600
patch3/256_rollout8_30ep256×25611m0.41418.06137.7304
patch3/64_rollout4_fast64×645m0.42510.51911.028
patch3/128_rollout4_30ep128×1285m0.57822.75742.888
patch3/256_rollout12_25ep256×25632m0.55313.66177.3304
idle4 retrain · 256×256 · 30 ep × 400 batches (today)
patch_sweep_retrain/patch_4256×2569m0.35110.99.9342
patch_sweep_retrain/patch_5256×2567m0.37411.310.3403
patch_sweep_retrain/patch_6256×2565m0.38711.912.0495
patch sweep · 128×128 · 15 ep (under-trained baseline)
patch_sweep/patch_2128×128~2m0.57211.93341.979
patch_sweep/patch_3128×128~2m1.91517.12353.088
patch_sweep/patch_4128×128~3m0.86317.52222.6107
patch_sweep/patch_5128×128~3m0.81012.01704.1138
patch_sweep/patch_6128×128~4m1.52713.71594.5184
patch_sweep/patch_7128×128~6m1.02832.23847.1249
patch_sweep/patch_8128×128~8m1.88132.22149.5335
patch_sweep/patch_9128×128~10m0.84718.119416.2445
all models · speed vs accuracy · color = generation

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!

Gameplay clip

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